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;