forked from excalidraw/excalidraw
-
Notifications
You must be signed in to change notification settings - Fork 24
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add Trans component for interpolating JSX in translations (exca…
…lidraw#6534) * feat: add Trans component * Add comments * tweak * Move brave to trans component * fix test and tweaks * remove any * fix * fix * comment * replace render function type * Use tags for Trans * Fix a typo Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com> * Cleanup, add comments, add support for kebab case * tweaks --------- Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com> Co-authored-by: dwelle <luzar.david@gmail.com>
- Loading branch information
Showing
5 changed files
with
280 additions
and
64 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
import { render } from "@testing-library/react"; | ||
|
||
import fallbackLangData from "../locales/en.json"; | ||
|
||
import Trans from "./Trans"; | ||
|
||
describe("Test <Trans/>", () => { | ||
it("should translate the the strings correctly", () => { | ||
//@ts-ignore | ||
fallbackLangData.transTest = { | ||
key1: "Hello {{audience}}", | ||
key2: "Please <link>click the button</link> to continue.", | ||
key3: "Please <link>click {{location}}</link> to continue.", | ||
key4: "Please <link>click <bold>{{location}}</bold></link> to continue.", | ||
key5: "Please <connect-link>click the button</connect-link> to continue.", | ||
}; | ||
|
||
const { getByTestId } = render( | ||
<> | ||
<div data-testid="test1"> | ||
<Trans i18nKey="transTest.key1" audience="world" /> | ||
</div> | ||
<div data-testid="test2"> | ||
<Trans | ||
i18nKey="transTest.key2" | ||
link={(el) => <a href="https://example.com">{el}</a>} | ||
/> | ||
</div> | ||
<div data-testid="test3"> | ||
<Trans | ||
i18nKey="transTest.key3" | ||
link={(el) => <a href="https://example.com">{el}</a>} | ||
location="the button" | ||
/> | ||
</div> | ||
<div data-testid="test4"> | ||
<Trans | ||
i18nKey="transTest.key4" | ||
link={(el) => <a href="https://example.com">{el}</a>} | ||
location="the button" | ||
bold={(el) => <strong>{el}</strong>} | ||
/> | ||
</div> | ||
<div data-testid="test5"> | ||
<Trans | ||
i18nKey="transTest.key5" | ||
connect-link={(el) => <a href="https://example.com">{el}</a>} | ||
/> | ||
</div> | ||
</>, | ||
); | ||
|
||
expect(getByTestId("test1").innerHTML).toEqual("Hello world"); | ||
expect(getByTestId("test2").innerHTML).toEqual( | ||
`Please <a href="https://example.com">click the button</a> to continue.`, | ||
); | ||
expect(getByTestId("test3").innerHTML).toEqual( | ||
`Please <a href="https://example.com">click the button</a> to continue.`, | ||
); | ||
expect(getByTestId("test4").innerHTML).toEqual( | ||
`Please <a href="https://example.com">click <strong>the button</strong></a> to continue.`, | ||
); | ||
expect(getByTestId("test5").innerHTML).toEqual( | ||
`Please <a href="https://example.com">click the button</a> to continue.`, | ||
); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,169 @@ | ||
import React from "react"; | ||
|
||
import { useI18n } from "../i18n"; | ||
|
||
// Used for splitting i18nKey into tokens in Trans component | ||
// Example: | ||
// "Please <link>click {{location}}</link> to continue.".split(SPLIT_REGEX).filter(Boolean) | ||
// produces | ||
// ["Please ", "<link>", "click ", "{{location}}", "</link>", " to continue."] | ||
const SPLIT_REGEX = /({{[\w-]+}})|(<[\w-]+>)|(<\/[\w-]+>)/g; | ||
// Used for extracting "location" from "{{location}}" | ||
const KEY_REGEXP = /{{([\w-]+)}}/; | ||
// Used for extracting "link" from "<link>" | ||
const TAG_START_REGEXP = /<([\w-]+)>/; | ||
// Used for extracting "link" from "</link>" | ||
const TAG_END_REGEXP = /<\/([\w-]+)>/; | ||
|
||
const getTransChildren = ( | ||
format: string, | ||
props: { | ||
[key: string]: React.ReactNode | ((el: React.ReactNode) => React.ReactNode); | ||
}, | ||
): React.ReactNode[] => { | ||
const stack: { name: string; children: React.ReactNode[] }[] = [ | ||
{ | ||
name: "", | ||
children: [], | ||
}, | ||
]; | ||
|
||
format | ||
.split(SPLIT_REGEX) | ||
.filter(Boolean) | ||
.forEach((match) => { | ||
const tagStartMatch = match.match(TAG_START_REGEXP); | ||
const tagEndMatch = match.match(TAG_END_REGEXP); | ||
const keyMatch = match.match(KEY_REGEXP); | ||
|
||
if (tagStartMatch !== null) { | ||
// The match is <tag>. Set the tag name as the name if it's one of the | ||
// props, e.g. for "Please <link>click the button</link> to continue" | ||
// tagStartMatch[1] = "link" and props contain "link" then it will be | ||
// pushed to stack. | ||
const name = tagStartMatch[1]; | ||
if (props.hasOwnProperty(name)) { | ||
stack.push({ | ||
name, | ||
children: [], | ||
}); | ||
} else { | ||
console.warn( | ||
`Trans: missed to pass in prop ${name} for interpolating ${format}`, | ||
); | ||
} | ||
} else if (tagEndMatch !== null) { | ||
// If tag end match is found, this means we need to replace the content with | ||
// its actual value in prop e.g. format = "Please <link>click the | ||
// button</link> to continue", tagEndMatch is for "</link>", stack last item name = | ||
// "link" and props.link = (el) => <a | ||
// href="https://example.com">{el}</a> then its prop value will be | ||
// pushed to "link"'s children so on DOM when rendering it's rendered as | ||
// <a href="https://example.com">click the button</a> | ||
const name = tagEndMatch[1]; | ||
if (name === stack[stack.length - 1].name) { | ||
const item = stack.pop()!; | ||
const itemChildren = React.createElement( | ||
React.Fragment, | ||
{}, | ||
...item.children, | ||
); | ||
const fn = props[item.name]; | ||
if (typeof fn === "function") { | ||
stack[stack.length - 1].children.push(fn(itemChildren)); | ||
} | ||
} else { | ||
console.warn( | ||
`Trans: unexpected end tag ${match} for interpolating ${format}`, | ||
); | ||
} | ||
} else if (keyMatch !== null) { | ||
// The match is for {{key}}. Check if the key is present in props and set | ||
// the prop value as children of last stack item e.g. format = "Hello | ||
// {{name}}", key = "name" and props.name = "Excalidraw" then its prop | ||
// value will be pushed to "name"'s children so it's rendered on DOM as | ||
// "Hello Excalidraw" | ||
const name = keyMatch[1]; | ||
if (props.hasOwnProperty(name)) { | ||
stack[stack.length - 1].children.push(props[name] as React.ReactNode); | ||
} else { | ||
console.warn( | ||
`Trans: key ${name} not in props for interpolating ${format}`, | ||
); | ||
} | ||
} else { | ||
// If none of cases match means we just need to push the string | ||
// to stack eg - "Hello {{name}} Whats up?" "Hello", "Whats up" will be pushed | ||
stack[stack.length - 1].children.push(match); | ||
} | ||
}); | ||
|
||
if (stack.length !== 1) { | ||
console.warn(`Trans: stack not empty for interpolating ${format}`); | ||
} | ||
|
||
return stack[0].children; | ||
}; | ||
|
||
/* | ||
Trans component is used for translating JSX. | ||
```json | ||
{ | ||
"example1": "Hello {{audience}}", | ||
"example2": "Please <link>click the button</link> to continue.", | ||
"example3": "Please <link>click {{location}}</link> to continue.", | ||
"example4": "Please <link>click <bold>{{location}}</bold></link> to continue.", | ||
} | ||
``` | ||
```jsx | ||
<Trans i18nKey="example1" audience="world" /> | ||
<Trans | ||
i18nKey="example2" | ||
connectLink={(el) => <a href="https://example.com">{el}</a>} | ||
/> | ||
<Trans | ||
i18nKey="example3" | ||
connectLink={(el) => <a href="https://example.com">{el}</a>} | ||
location="the button" | ||
/> | ||
<Trans | ||
i18nKey="example4" | ||
connectLink={(el) => <a href="https://example.com">{el}</a>} | ||
location="the button" | ||
bold={(el) => <strong>{el}</strong>} | ||
/> | ||
``` | ||
Output: | ||
```html | ||
Hello world | ||
Please <a href="https://example.com">click the button</a> to continue. | ||
Please <a href="https://example.com">click the button</a> to continue. | ||
Please <a href="https://example.com">click <strong>the button</strong></a> to continue. | ||
``` | ||
*/ | ||
const Trans = ({ | ||
i18nKey, | ||
children, | ||
...props | ||
}: { | ||
i18nKey: string; | ||
[key: string]: React.ReactNode | ((el: React.ReactNode) => React.ReactNode); | ||
}) => { | ||
const { t } = useI18n(); | ||
|
||
// This is needed to avoid unique key error in list which gets rendered from getTransChildren | ||
return React.createElement( | ||
React.Fragment, | ||
{}, | ||
...getTransChildren(t(i18nKey), props), | ||
); | ||
}; | ||
|
||
export default Trans; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.