From 92104c7c3b65c24a9a5ebcc5cb9c59cd1bc160d5 Mon Sep 17 00:00:00 2001 From: Alexey Pyltsyn Date: Thu, 14 Oct 2021 19:39:41 +0300 Subject: [PATCH] feat: make Translate children optional (#5683) Co-authored-by: slorber --- .../src/index.d.ts | 22 ++--- .../src/client/exports/Translate.tsx | 29 +++--- .../exports/__tests__/Translate.test.tsx | 31 +++++++ .../__tests__/translationsExtractor.test.ts | 10 ++- .../translations/translationsExtractor.ts | 90 ++++++++++--------- website/docs/docusaurus-core.md | 10 +++ 6 files changed, 123 insertions(+), 69 deletions(-) create mode 100644 packages/docusaurus/src/client/exports/__tests__/Translate.test.tsx diff --git a/packages/docusaurus-module-type-aliases/src/index.d.ts b/packages/docusaurus-module-type-aliases/src/index.d.ts index 03db7a2a16..9714e56873 100644 --- a/packages/docusaurus-module-type-aliases/src/index.d.ts +++ b/packages/docusaurus-module-type-aliases/src/index.d.ts @@ -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 = Partial< - InterpolateProps - > & { - 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 = + | ({[key in MessageKey]: string} & {id?: string}) + | ({[key in MessageKey]?: string} & {id: string}); + + export type TranslateParam = IdOrMessage<'message'> & { description?: string; values?: InterpolateValues; }; @@ -160,9 +160,9 @@ declare module '@docusaurus/Translate' { values?: InterpolateValues, ): string; - export type TranslateProps = InterpolateProps & { - id?: string; + export type TranslateProps = IdOrMessage<'children'> & { description?: string; + values?: InterpolateValues; }; export default function Translate( diff --git a/packages/docusaurus/src/client/exports/Translate.tsx b/packages/docusaurus/src/client/exports/Translate.tsx index 4056d6a440..a7cacd490f 100644 --- a/packages/docusaurus/src/client/exports/Translate.tsx +++ b/packages/docusaurus/src/client/exports/Translate.tsx @@ -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( {message, id}: TranslateParam, values?: InterpolateValues, ): string { - const localizedMessage = getLocalizedMessage({message, id}) ?? message; + const localizedMessage = getLocalizedMessage({message, id}); return interpolate(localizedMessage, values); } @@ -42,16 +45,14 @@ export default function Translate({ children, id, values, -}: TranslateProps): JSX.Element { - if (typeof children !== 'string') { +}: TranslateProps): ReactNode { + if (children && typeof children !== 'string') { console.warn('Illegal children', children); throw new Error( 'The Docusaurus component only accept simple string values', ); } - const localizedMessage: string = - getLocalizedMessage({message: children, id}) ?? children; - - return {localizedMessage}; + const localizedMessage: string = getLocalizedMessage({message: children, id}); + return interpolate(localizedMessage, values); } diff --git a/packages/docusaurus/src/client/exports/__tests__/Translate.test.tsx b/packages/docusaurus/src/client/exports/__tests__/Translate.test.tsx new file mode 100644 index 0000000000..98701a456c --- /dev/null +++ b/packages/docusaurus/src/client/exports/__tests__/Translate.test.tsx @@ -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"`, + ); + }); +}); diff --git a/packages/docusaurus/src/server/translations/__tests__/translationsExtractor.test.ts b/packages/docusaurus/src/server/translations/__tests__/translationsExtractor.test.ts index 178164265b..46d91cc90b 100644 --- a/packages/docusaurus/src/server/translations/__tests__/translationsExtractor.test.ts +++ b/packages/docusaurus/src/server/translations/__tests__/translationsExtractor.test.ts @@ -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 (
+ +
); } @@ -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 component', async () => { + test('extract from a components', async () => { const {sourceCodeFilePath} = await createTmpSourceCodeFile({ extension: 'js', content: ` @@ -122,6 +125,8 @@ export default function MyComponent() { code message + + ); } @@ -137,6 +142,7 @@ export default function MyComponent() { sourceCodeFilePath, translations: { codeId: {message: 'code message', description: 'code description'}, + codeId1: {message: 'codeId1'}, }, warnings: [], }); diff --git a/packages/docusaurus/src/server/translations/translationsExtractor.ts b/packages/docusaurus/src/server/translations/translationsExtractor.ts index 9878905c75..5ec63ba12f 100644 --- a/packages/docusaurus/src/server/translations/translationsExtractor.ts +++ b/packages/docusaurus/src/server/translations/translationsExtractor.ts @@ -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 text.'; - } - 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 = {}; @@ -228,9 +224,9 @@ function extractSourceCodeAstTranslations( return attributeValueEvaluated.value; } else { warnings.push( - ` prop=${propName} should be a statically evaluable object.\nExample: Message\nDynamically constructed values are not allowed, because they prevent translations to be extracted.\n${sourceFileWarningPart( + ` prop=${propName} should be a statically evaluable object.\nExample: Message\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(` + without children must have id prop.\nExample: \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 text.\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)}`, + )}`, ); } }, diff --git a/website/docs/docusaurus-core.md b/website/docs/docusaurus-core.md index d144820e93..eb456c6a07 100644 --- a/website/docs/docusaurus-core.md +++ b/website/docs/docusaurus-core.md @@ -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 + +``` + +::: + ## Hooks {#hooks} ### `useDocusaurusContext` {#usedocusauruscontext}