From 90feb4ba4a1b58c2a9a80bdae3f25894d8743efc Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Mon, 20 Feb 2023 16:26:30 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8C=90(react)=20add=20i18n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We need to be able to use localized texts for various components, like for accessible labels. We decided to setup a lightweight implementation of localizable to avoid relying on an existing heavy library. The Provider includes by default full translations for english, and it is also made to be able to load easily any custom locale directly from the Provider. --- .circleci/config.yml | 22 ++++ .gitignore | 3 +- Makefile | 18 ++- crowdin/config.yml | 23 ++++ docker-compose.yml | 10 +- env.d/crowdin.dist | 3 + .../react/src/components/Provider/Locales.ts | 4 + .../src/components/Provider/index.spec.tsx | 105 ++++++++++++++++++ .../react/src/components/Provider/index.tsx | 92 +++++++++++++++ packages/react/src/index.ts | 1 + packages/react/src/locales/en-US.json | 20 ++++ packages/react/src/locales/fr-FR.json | 20 ++++ packages/react/src/types/index.ts | 3 + packages/react/src/utils/index.ts | 1 + 14 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 crowdin/config.yml create mode 100644 env.d/crowdin.dist create mode 100644 packages/react/src/components/Provider/Locales.ts create mode 100644 packages/react/src/components/Provider/index.spec.tsx create mode 100644 packages/react/src/components/Provider/index.tsx create mode 100644 packages/react/src/locales/en-US.json create mode 100644 packages/react/src/locales/fr-FR.json create mode 100644 packages/react/src/types/index.ts create mode 100644 packages/react/src/utils/index.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index f4568ad..0bddfec 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -101,6 +101,20 @@ jobs: name: Run test suites over all workspaces command: yarn test + # ---- Internationalization ---- + crowdin-upload: + docker: + - image: crowdin/cli:3.10.0 + auth: + username: $DOCKER_USER + password: $DOCKER_PASS + working_directory: ~/cunningham + steps: + - *checkout_cunningham + - run: + name: upload translation files to crowdin + command: crowdin upload sources -c crowdin/config.yml + # ---- Deploy ---- publish-storybook: docker: @@ -167,6 +181,14 @@ workflows: filters: tags: only: /.*/ + # ---- Internationalization ---- + - crowdin-upload: + filters: + branches: + only: + - main + requires: + - build # ---- Codebase ---- - build: filters: diff --git a/.gitignore b/.gitignore index 6f9f496..d0d5372 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,5 @@ dist *.sln *.sw? -vite.config.ts.timestamp-* \ No newline at end of file +vite.config.ts.timestamp-* +env.d \ No newline at end of file diff --git a/Makefile b/Makefile index d1cccb2..fbaa915 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,7 @@ COMPOSE_RUN = $(COMPOSE) run --rm --service-ports # permission error. COMPOSE_RUN_NODE = $(COMPOSE_RUN) -e HOME="/tmp" node YARN = $(COMPOSE_RUN_NODE) yarn +CROWDIN = $(COMPOSE_RUN) crowdin crowdin # ============================================================================== # RULES @@ -54,7 +55,9 @@ install: ## install all repos dependencies. .PHONY: install bootstrap: ## install all repos dependencies and build them too. -bootstrap: build +bootstrap: \ + env.d/crowdin \ + build .PHONY: bootstrap dev: ## watch changes in apps and packages. @@ -82,6 +85,19 @@ deploy: install @$(YARN) deploy .PHONY: deploy +# -- Misc +env.d/crowdin: + cp env.d/crowdin.dist env.d/crowdin + +# -- Internationalization +crowdin-upload: + @$(CROWDIN) upload sources -c crowdin/config.yml +.PHONY: crowdin-upload + +crowdin-download: + @$(CROWDIN) download -c crowdin/config.yml +.PHONY: crowdin-download + clean: ## restore repository state as it was freshly cloned git clean -idx .PHONY: clean diff --git a/crowdin/config.yml b/crowdin/config.yml new file mode 100644 index 0000000..7d36f3b --- /dev/null +++ b/crowdin/config.yml @@ -0,0 +1,23 @@ +# +# Your crowdin's credentials +# +api_token_env: CROWDIN_API_TOKEN +project_id_env: CROWDIN_PROJECT_ID +base_path_env: CROWDIN_BASE_PATH + +# +# Choose file structure in crowdin +# e.g. true or false +# +preserve_hierarchy: true + +# +# Files configuration +# +files: [ + { + source : "/packages/react/src/locales/en-US.json", + dest: "/packages/react/en-US.json", + translation : "/packages/react/src/locales/%locale%.json" + } +] diff --git a/docker-compose.yml b/docker-compose.yml index 2733145..348c79e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,4 +10,12 @@ services: - "3201:3201" - "6006:6006" volumes: - - .:/app \ No newline at end of file + - .:/app + + crowdin: + image: crowdin/cli:3.10.0 + user: "${DOCKER_USER:-1000}" + working_dir: /app + env_file: env.d/crowdin + volumes: + - ".:/app" diff --git a/env.d/crowdin.dist b/env.d/crowdin.dist new file mode 100644 index 0000000..f3de0de --- /dev/null +++ b/env.d/crowdin.dist @@ -0,0 +1,3 @@ +CROWDIN_API_TOKEN=Your-Api-Token +CROWDIN_PROJECT_ID=Your-Project-Id +CROWDIN_BASE_PATH=/app diff --git a/packages/react/src/components/Provider/Locales.ts b/packages/react/src/components/Provider/Locales.ts new file mode 100644 index 0000000..b868146 --- /dev/null +++ b/packages/react/src/components/Provider/Locales.ts @@ -0,0 +1,4 @@ +export enum Locales { + enUS = "en-US", + frFR = "fr-FR", +} diff --git a/packages/react/src/components/Provider/index.spec.tsx b/packages/react/src/components/Provider/index.spec.tsx new file mode 100644 index 0000000..420bf77 --- /dev/null +++ b/packages/react/src/components/Provider/index.spec.tsx @@ -0,0 +1,105 @@ +import { render, screen } from "@testing-library/react"; +import React, { PropsWithChildren, useMemo, useState } from "react"; +import userEvent from "@testing-library/user-event"; +import { CunninghamProvider, useCunningham } from "components/Provider/index"; +import * as enUS from "locales/en-US.json"; +import { Button } from "components/Button"; + +describe("", () => { + it("should render", () => { + render( + +

Hi

+
+ ); + screen.getByRole("heading", { level: 1, name: "Hi" }); + }); + + it("should render accurate translation", () => { + const Wrapper = (props: PropsWithChildren) => { + return {props.children}; + }; + const Wrapped = () => { + const { t } = useCunningham(); + return

{t("components.provider.test", { name: "Bob" })}

; + }; + + render(, { wrapper: Wrapper }); + screen.getByRole("heading", { level: 1, name: "This is a test: Bob" }); + }); + + it("should render custom translations and switching", async () => { + const Wrapper = (props: PropsWithChildren) => { + // Create a new locale with a custom translation. + const zuBU = useMemo(() => { + const base = JSON.parse(JSON.stringify(enUS)); + base.components.provider.test = "Zubu Zubu"; + return base; + }, []); + + const [currentLocale, setCurrentLocale] = useState("en-US"); + + const onSwitch = () => { + setCurrentLocale(currentLocale === "en-US" ? "zu-BU" : "en-US"); + }; + + return ( + + {props.children} + + + ); + }; + const Wrapped = () => { + const { t } = useCunningham(); + return

{t("components.provider.test", { name: "Bob" })}

; + }; + + render(, { wrapper: Wrapper }); + screen.getByRole("heading", { level: 1, name: "This is a test: Bob" }); + + const switchButton = screen.getByRole("button", { name: "Switch" }); + const user = userEvent.setup(); + user.click(switchButton); + + await screen.findByRole("heading", { level: 1, name: "Zubu Zubu" }); + }); + + it("should use default locale for undefined translations in current locale", () => { + const Wrapper = (props: PropsWithChildren) => { + return ( + + {props.children} + + ); + }; + const Wrapped = () => { + const { t } = useCunningham(); + return

{t("components.provider.test", { name: "Bob" })}

; + }; + + render(, { wrapper: Wrapper }); + screen.getByRole("heading", { level: 1, name: "This is a test: Bob" }); + }); + + it("should use key as translation when the key is not defined across current locale and default locale", () => { + const Wrapper = (props: PropsWithChildren) => { + return {props.children}; + }; + const Wrapped = () => { + const { t } = useCunningham(); + return

{t("components.will_never_exist")}

; + }; + + render(, { wrapper: Wrapper }); + screen.getByRole("heading", { + level: 1, + name: "components.will_never_exist", + }); + }); +}); diff --git a/packages/react/src/components/Provider/index.tsx b/packages/react/src/components/Provider/index.tsx new file mode 100644 index 0000000..08e29ee --- /dev/null +++ b/packages/react/src/components/Provider/index.tsx @@ -0,0 +1,92 @@ +import React, { + createContext, + PropsWithChildren, + useContext, + useMemo, +} from "react"; +import * as enUS from "locales/en-US.json"; +import * as frFR from "locales/fr-FR.json"; +import { PartialNested } from "types"; +import { Locales } from "components/Provider/Locales"; + +type TranslationSet = PartialNested; + +const CunninghamContext = createContext< + | undefined + | { + t: (key: string, vars?: Record) => string; + } +>(undefined); + +export const useCunningham = () => { + const context = useContext(CunninghamContext); + if (context === undefined) { + throw new Error("useCunningham must be used within a CunninghamProvider."); + } + return context; +}; + +interface Props extends PropsWithChildren { + customLocales?: Record; + currentLocale?: string; +} + +export const DEFAULT_LOCALE = Locales.enUS; +export const SUPPORTED_LOCALES = Object.values(Locales); + +const findTranslation = ( + key: string, + locale: TranslationSet +): string | undefined => { + const [namespace, ...keys] = key.split("."); + return keys.reduce((acc, subKey) => acc[subKey], (locale as any)[namespace]); +}; + +export const CunninghamProvider = ({ + currentLocale = DEFAULT_LOCALE, + customLocales, + children, +}: Props) => { + const locales: Record = useMemo( + () => ({ + [DEFAULT_LOCALE]: enUS, + "fr-FR": frFR, + ...customLocales, + }), + [customLocales] + ); + + const locale = useMemo(() => { + if (!locales[currentLocale]) { + return locales[DEFAULT_LOCALE]; + } + return locales[currentLocale]; + }, [currentLocale, locales]); + + const context = useMemo( + () => ({ + t: (key: string, vars?: Record) => { + let message: string = + findTranslation(key, locale) ?? + findTranslation(key, locales[DEFAULT_LOCALE]) ?? + key; + + // Replace vars in message from vars in form of {varName}. + if (vars) { + Object.keys(vars).forEach((varName) => { + message = message?.replace(`{${varName}}`, "" + vars[varName]); + }); + } + + return message; + }, + }), + [currentLocale, locales] + ); + + return ( + + {children} + + ); +}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 126f5b2..368e96f 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,3 +1,4 @@ import "./index.scss"; export * from "./components/Button"; +export * from "./components/Provider"; diff --git a/packages/react/src/locales/en-US.json b/packages/react/src/locales/en-US.json new file mode 100644 index 0000000..cfc29f2 --- /dev/null +++ b/packages/react/src/locales/en-US.json @@ -0,0 +1,20 @@ +{ + "components": { + "pagination": { + "goto_label": "Go to", + "goto_label_aria": "Go to any page", + "next_aria": "Go to next page", + "previous_aria": "Go to previous page", + "goto_page_aria": "Go to page {page}", + "current_page_aria": "You are currently on page {page}" + }, + "datagrid": { + "empty": "This table is empty", + "empty_alt": "Illustration of an empty table", + "loader_aria": "Loading data" + }, + "provider": { + "test": "This is a test: {name}" + } + } +} \ No newline at end of file diff --git a/packages/react/src/locales/fr-FR.json b/packages/react/src/locales/fr-FR.json new file mode 100644 index 0000000..7f7830c --- /dev/null +++ b/packages/react/src/locales/fr-FR.json @@ -0,0 +1,20 @@ +{ + "components": { + "pagination": { + "goto_label": "Aller à", + "goto_label_aria": "Aller à la page", + "next_aria": "Aller à la page suivante", + "previous_aria": "Aller à la page précédente", + "goto_page_aria": "Aller à la page {page}", + "current_page_aria": "Vous êtes actuellement sur la page {page}" + }, + "datagrid": { + "empty": "Ce tableau est vide", + "empty_alt": "Illustration d'un tableau vide", + "loader_aria": "Loading data" + }, + "provider": { + "test": "Ceci est un test : {name}" + } + } +} \ No newline at end of file diff --git a/packages/react/src/types/index.ts b/packages/react/src/types/index.ts new file mode 100644 index 0000000..d864376 --- /dev/null +++ b/packages/react/src/types/index.ts @@ -0,0 +1,3 @@ +export type PartialNested = { + [K in keyof T]?: T extends object ? PartialNested : T[K]; +}; diff --git a/packages/react/src/utils/index.ts b/packages/react/src/utils/index.ts new file mode 100644 index 0000000..efd238e --- /dev/null +++ b/packages/react/src/utils/index.ts @@ -0,0 +1 @@ +export const noop = () => undefined;