Browse Source

feat: Add Trans component for interpolating JSX in translations (#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 <[email protected]>

* Cleanup, add comments, add support for kebab case

* tweaks

---------

Co-authored-by: Aakansha Doshi <[email protected]>
Co-authored-by: dwelle <[email protected]>
Luka Zakrajšek 2 years ago
parent
commit
1184a8c0e9

+ 29 - 28
src/components/BraveMeasureTextError.tsx

@@ -1,39 +1,40 @@
-import { t } from "../i18n";
+import Trans from "./Trans";
+
 const BraveMeasureTextError = () => {
   return (
     <div data-testid="brave-measure-text-error">
       <p>
-        {t("errors.brave_measure_text_error.start")} &nbsp;
-        <span style={{ fontWeight: 600 }}>
-          {t("errors.brave_measure_text_error.aggressive_block_fingerprint")}
-        </span>{" "}
-        {t("errors.brave_measure_text_error.setting_enabled")}.
-        <br />
-        <br />
-        {t("errors.brave_measure_text_error.break")}{" "}
-        <span style={{ fontWeight: 600 }}>
-          {t("errors.brave_measure_text_error.text_elements")}
-        </span>{" "}
-        {t("errors.brave_measure_text_error.in_your_drawings")}.
+        <Trans
+          i18nKey="errors.brave_measure_text_error.line1"
+          bold={(el) => <span style={{ fontWeight: 600 }}>{el}</span>}
+        />
+      </p>
+      <p>
+        <Trans
+          i18nKey="errors.brave_measure_text_error.line2"
+          bold={(el) => <span style={{ fontWeight: 600 }}>{el}</span>}
+        />
       </p>
       <p>
-        {t("errors.brave_measure_text_error.strongly_recommend")}{" "}
-        <a href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser">
-          {" "}
-          {t("errors.brave_measure_text_error.steps")}
-        </a>{" "}
-        {t("errors.brave_measure_text_error.how")}.
+        <Trans
+          i18nKey="errors.brave_measure_text_error.line3"
+          link={(el) => (
+            <a href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser">
+              {el}
+            </a>
+          )}
+        />
       </p>
       <p>
-        {t("errors.brave_measure_text_error.disable_setting")}{" "}
-        <a href="https://github.com/excalidraw/excalidraw/issues/new">
-          {t("errors.brave_measure_text_error.issue")}
-        </a>{" "}
-        {t("errors.brave_measure_text_error.write")}{" "}
-        <a href="https://discord.gg/UexuTaE">
-          {t("errors.brave_measure_text_error.discord")}
-        </a>
-        .
+        <Trans
+          i18nKey="errors.brave_measure_text_error.line4"
+          issueLink={(el) => (
+            <a href="https://github.com/excalidraw/excalidraw/issues/new">
+              {el}
+            </a>
+          )}
+          discordLink={(el) => <a href="https://discord.gg/UexuTaE">{el}.</a>}
+        />
       </p>
     </div>
   );

+ 67 - 0
src/components/Trans.test.tsx

@@ -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.`,
+    );
+  });
+});

+ 169 - 0
src/components/Trans.tsx

@@ -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;

+ 11 - 24
src/components/__snapshots__/App.test.tsx.snap

@@ -5,59 +5,46 @@ exports[`Test <App/> should show error modal when using brave and measureText AP
   data-testid="brave-measure-text-error"
 >
   <p>
-    Looks like you are using Brave browser with the
-      
+    Looks like you are using Brave browser with the 
     <span
       style="font-weight: 600;"
     >
       Aggressively Block Fingerprinting
     </span>
-     
-    setting enabled
-    .
-    <br />
-    <br />
-    This could result in breaking the
-     
+     setting enabled.
+  </p>
+  <p>
+    This could result in breaking the 
     <span
       style="font-weight: 600;"
     >
       Text Elements
     </span>
-     
-    in your drawings
-    .
+     in your drawings.
   </p>
   <p>
-    We strongly recommend disabling this setting. You can follow
-     
+    We strongly recommend disabling this setting. You can follow 
     <a
       href="http://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#turning-off-aggresive-block-fingerprinting-in-brave-browser"
     >
-       
       these steps
     </a>
-     
-    on how to do so
-    .
+     on how to do so.
   </p>
   <p>
-     If disabling this setting doesn't fix the display of text elements, please open an
-     
+     If disabling this setting doesn't fix the display of text elements, please open an 
     <a
       href="https://github.com/excalidraw/excalidraw/issues/new"
     >
       issue
     </a>
-     
-    on our GitHub, or write us on
-     
+     on our GitHub, or write us on 
     <a
       href="https://discord.gg/UexuTaE"
     >
       Discord
+      .
     </a>
-    .
   </p>
 </div>
 `;

+ 4 - 12
src/locales/en.json

@@ -208,18 +208,10 @@
     "collabSaveFailed": "Couldn't save to the backend database. If problems persist, you should save your file locally to ensure you don't lose your work.",
     "collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work.",
     "brave_measure_text_error": {
-      "start": "Looks like you are using Brave browser with the",
-      "aggressive_block_fingerprint": "Aggressively Block Fingerprinting",
-      "setting_enabled": "setting enabled",
-      "break": "This could result in breaking the",
-      "text_elements": "Text Elements",
-      "in_your_drawings": "in your drawings",
-      "strongly_recommend": "We strongly recommend disabling this setting. You can follow",
-      "steps": "these steps",
-      "how": "on how to do so",
-      "disable_setting": " If disabling this setting doesn't fix the display of text elements, please open an",
-      "issue": "issue",
-      "write": "on our GitHub, or write us on",
+      "line1": "Looks like you are using Brave browser with the <bold>Aggressively Block Fingerprinting</bold> setting enabled.",
+      "line2": "This could result in breaking the <bold>Text Elements</bold> in your drawings.",
+      "line3": "We strongly recommend disabling this setting. You can follow <link>these steps</link> on how to do so.",
+      "line4": " If disabling this setting doesn't fix the display of text elements, please open an <issueLink>issue</issueLink> on our GitHub, or write us on <discordLink>Discord</discordLink>",
       "discord": "Discord"
     }
   },