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;