🌐(react) add i18n
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.
This commit is contained in:
@@ -101,6 +101,20 @@ jobs:
|
|||||||
name: Run test suites over all workspaces
|
name: Run test suites over all workspaces
|
||||||
command: yarn test
|
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 ----
|
# ---- Deploy ----
|
||||||
publish-storybook:
|
publish-storybook:
|
||||||
docker:
|
docker:
|
||||||
@@ -167,6 +181,14 @@ workflows:
|
|||||||
filters:
|
filters:
|
||||||
tags:
|
tags:
|
||||||
only: /.*/
|
only: /.*/
|
||||||
|
# ---- Internationalization ----
|
||||||
|
- crowdin-upload:
|
||||||
|
filters:
|
||||||
|
branches:
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
requires:
|
||||||
|
- build
|
||||||
# ---- Codebase ----
|
# ---- Codebase ----
|
||||||
- build:
|
- build:
|
||||||
filters:
|
filters:
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -26,3 +26,4 @@ dist
|
|||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
env.d
|
||||||
18
Makefile
18
Makefile
@@ -43,6 +43,7 @@ COMPOSE_RUN = $(COMPOSE) run --rm --service-ports
|
|||||||
# permission error.
|
# permission error.
|
||||||
COMPOSE_RUN_NODE = $(COMPOSE_RUN) -e HOME="/tmp" node
|
COMPOSE_RUN_NODE = $(COMPOSE_RUN) -e HOME="/tmp" node
|
||||||
YARN = $(COMPOSE_RUN_NODE) yarn
|
YARN = $(COMPOSE_RUN_NODE) yarn
|
||||||
|
CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# RULES
|
# RULES
|
||||||
@@ -54,7 +55,9 @@ install: ## install all repos dependencies.
|
|||||||
.PHONY: install
|
.PHONY: install
|
||||||
|
|
||||||
bootstrap: ## install all repos dependencies and build them too.
|
bootstrap: ## install all repos dependencies and build them too.
|
||||||
bootstrap: build
|
bootstrap: \
|
||||||
|
env.d/crowdin \
|
||||||
|
build
|
||||||
.PHONY: bootstrap
|
.PHONY: bootstrap
|
||||||
|
|
||||||
dev: ## watch changes in apps and packages.
|
dev: ## watch changes in apps and packages.
|
||||||
@@ -82,6 +85,19 @@ deploy: install
|
|||||||
@$(YARN) deploy
|
@$(YARN) deploy
|
||||||
.PHONY: 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
|
clean: ## restore repository state as it was freshly cloned
|
||||||
git clean -idx
|
git clean -idx
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
|
|||||||
23
crowdin/config.yml
Normal file
23
crowdin/config.yml
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -11,3 +11,11 @@ services:
|
|||||||
- "6006:6006"
|
- "6006:6006"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
|
||||||
|
crowdin:
|
||||||
|
image: crowdin/cli:3.10.0
|
||||||
|
user: "${DOCKER_USER:-1000}"
|
||||||
|
working_dir: /app
|
||||||
|
env_file: env.d/crowdin
|
||||||
|
volumes:
|
||||||
|
- ".:/app"
|
||||||
|
|||||||
3
env.d/crowdin.dist
Normal file
3
env.d/crowdin.dist
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
CROWDIN_API_TOKEN=Your-Api-Token
|
||||||
|
CROWDIN_PROJECT_ID=Your-Project-Id
|
||||||
|
CROWDIN_BASE_PATH=/app
|
||||||
4
packages/react/src/components/Provider/Locales.ts
Normal file
4
packages/react/src/components/Provider/Locales.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export enum Locales {
|
||||||
|
enUS = "en-US",
|
||||||
|
frFR = "fr-FR",
|
||||||
|
}
|
||||||
105
packages/react/src/components/Provider/index.spec.tsx
Normal file
105
packages/react/src/components/Provider/index.spec.tsx
Normal file
@@ -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("<CunninghamProvider />", () => {
|
||||||
|
it("should render", () => {
|
||||||
|
render(
|
||||||
|
<CunninghamProvider>
|
||||||
|
<h1>Hi</h1>
|
||||||
|
</CunninghamProvider>
|
||||||
|
);
|
||||||
|
screen.getByRole("heading", { level: 1, name: "Hi" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render accurate translation", () => {
|
||||||
|
const Wrapper = (props: PropsWithChildren) => {
|
||||||
|
return <CunninghamProvider>{props.children}</CunninghamProvider>;
|
||||||
|
};
|
||||||
|
const Wrapped = () => {
|
||||||
|
const { t } = useCunningham();
|
||||||
|
return <h1>{t("components.provider.test", { name: "Bob" })}</h1>;
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<Wrapped />, { 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 (
|
||||||
|
<CunninghamProvider
|
||||||
|
customLocales={{
|
||||||
|
"zu-BU": zuBU,
|
||||||
|
}}
|
||||||
|
currentLocale={currentLocale}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
<Button onClick={onSwitch}>Switch</Button>
|
||||||
|
</CunninghamProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const Wrapped = () => {
|
||||||
|
const { t } = useCunningham();
|
||||||
|
return <h1>{t("components.provider.test", { name: "Bob" })}</h1>;
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<Wrapped />, { 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 (
|
||||||
|
<CunninghamProvider currentLocale="tu-TU">
|
||||||
|
{props.children}
|
||||||
|
</CunninghamProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const Wrapped = () => {
|
||||||
|
const { t } = useCunningham();
|
||||||
|
return <h1>{t("components.provider.test", { name: "Bob" })}</h1>;
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<Wrapped />, { 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 <CunninghamProvider>{props.children}</CunninghamProvider>;
|
||||||
|
};
|
||||||
|
const Wrapped = () => {
|
||||||
|
const { t } = useCunningham();
|
||||||
|
return <h1>{t("components.will_never_exist")}</h1>;
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<Wrapped />, { wrapper: Wrapper });
|
||||||
|
screen.getByRole("heading", {
|
||||||
|
level: 1,
|
||||||
|
name: "components.will_never_exist",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
92
packages/react/src/components/Provider/index.tsx
Normal file
92
packages/react/src/components/Provider/index.tsx
Normal file
@@ -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<typeof enUS>;
|
||||||
|
|
||||||
|
const CunninghamContext = createContext<
|
||||||
|
| undefined
|
||||||
|
| {
|
||||||
|
t: (key: string, vars?: Record<string, string | number>) => 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<string, TranslationSet>;
|
||||||
|
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<string, TranslationSet> = 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<string, string | number>) => {
|
||||||
|
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 (
|
||||||
|
<CunninghamContext.Provider value={context}>
|
||||||
|
{children}
|
||||||
|
</CunninghamContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
import "./index.scss";
|
import "./index.scss";
|
||||||
|
|
||||||
export * from "./components/Button";
|
export * from "./components/Button";
|
||||||
|
export * from "./components/Provider";
|
||||||
|
|||||||
20
packages/react/src/locales/en-US.json
Normal file
20
packages/react/src/locales/en-US.json
Normal file
@@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
packages/react/src/locales/fr-FR.json
Normal file
20
packages/react/src/locales/fr-FR.json
Normal file
@@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
packages/react/src/types/index.ts
Normal file
3
packages/react/src/types/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export type PartialNested<T> = {
|
||||||
|
[K in keyof T]?: T extends object ? PartialNested<T[K]> : T[K];
|
||||||
|
};
|
||||||
1
packages/react/src/utils/index.ts
Normal file
1
packages/react/src/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const noop = () => undefined;
|
||||||
Reference in New Issue
Block a user