Skip to content

Commit 726bd96

Browse files
authored
Basic translation support using Fluent (#73)
* Basic translation support using Fluent * Add german translation by @Techassi * Allow users to configure their preferred language * Show human-friendly language names in the picker * Detect language preferences from navigator.languages * LanguageProvider no longer takes languages * Fix eslint warnings * Translate login page * Reorganize FTLs * Don't fail fatally for untranslated strings * Update german translation * Clean up l10n naming * Spacing and more naming * Docs * Use === for nullish comparisons * REALLY_MAKE_THE_CONSTS_SCREAM
1 parent affea9b commit 726bd96

File tree

15 files changed

+238
-17
lines changed

15 files changed

+238
-17
lines changed

web/.eslintrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
}
3131
}
3232
],
33+
"unicorn/no-useless-undefined": "off",
3334
"@typescript-eslint/no-unused-vars": [
3435
"error",
3536
{

web/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
"vitest": "^0.31.1"
3434
},
3535
"dependencies": {
36+
"@fluent/bundle": "^0.18.0",
37+
"@fluent/langneg": "^0.7.0",
38+
"@fluent/sequence": "^0.8.0",
3639
"@solid-devtools/overlay": "^0.27.7",
3740
"@solidjs/router": "^0.8.2",
3841
"@unocss/reset": "^0.51.12",

web/src/components/datatable.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { For, JSX, Show, createMemo, createSignal } from 'solid-js';
22
import { Button } from './button';
33
import { SearchInput } from './form/search';
44
import { LoadingBar } from './loading';
5+
import { translate } from '../localization';
56

67
export interface DataTableColumn<T> {
78
label: string;
@@ -57,7 +58,9 @@ export function DataTable<T>(props: DataTableProps<T>): JSX.Element {
5758
<div class='flex-grow' />
5859
{props.extraButtons}
5960
<Show when={props.refresh}>
60-
<Button onClick={() => props.refresh?.()}>Refresh</Button>
61+
<Button onClick={() => props.refresh?.()}>
62+
{translate('refresh')}
63+
</Button>
6164
</Show>
6265
</div>
6366
<table class='font-sans border-collapse text-left w-full'>

web/src/components/header.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { A } from '@solidjs/router';
22
import { ParentProps, Show } from 'solid-js';
33
import logo from '../resources/logo.png';
44
import { logOut } from '../api/session';
5+
import { translate } from '../localization';
6+
import { LanguagePicker } from './language';
57

68
interface NavItemProps {
79
href?: string;
@@ -56,9 +58,12 @@ export const Header = () => {
5658
</A>
5759
</h1>
5860
<ul class='flex-auto m-0 p-0 flex'>
59-
<NavItem href='/stacklets'>Stacklets</NavItem>
61+
<NavItem href='/stacklets'>{translate('stacklet--list')}</NavItem>
6062
<li class='flex-grow' />
61-
<NavItem onClick={() => logOut()}>Log out</NavItem>
63+
<LanguagePicker />
64+
<NavItem onClick={() => logOut()}>
65+
{translate('login--log-out')}
66+
</NavItem>
6267
</ul>
6368
</nav>
6469
);

web/src/components/language.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { For } from 'solid-js';
2+
import { requireLanguageContext, translate } from '../localization';
3+
import { Some } from '../types/option';
4+
5+
export const LanguagePicker = () => {
6+
const context = requireLanguageContext();
7+
return (
8+
<select
9+
onInput={(event) => context.setUserLocale(Some(event.target.value))}
10+
>
11+
<For each={Object.keys(context.availableLocales())}>
12+
{(locale) => (
13+
<option
14+
value={locale}
15+
selected={context.activeLocales()[0] === locale}
16+
>
17+
{translate('locale-name', {}, { overrideLocales: [locale] })}
18+
</option>
19+
)}
20+
</For>
21+
</select>
22+
);
23+
};

web/src/localization/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Cockpit Localization
2+
3+
We use [Fluent](https://projectfluent.org/) to power our translations.
4+
5+
Each translation is stored as a file in the `locale/` folder, following the naming convention `locale/{langtag}.ftl`, where
6+
`{langtag}` IETF language tag (for example: `en` for "generic english", or `en-US` for "US english").
7+
8+
Translation keys are named according using `kebab-case`, with `--` used as the hierarchy separator (for example:
9+
`category--long-key`).

web/src/localization/index.tsx

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { FluentBundle, FluentResource, FluentVariable } from '@fluent/bundle';
2+
import { mapBundleSync } from '@fluent/sequence';
3+
import { negotiateLanguages } from '@fluent/langneg';
4+
import { JSX, createContext, createMemo, useContext } from 'solid-js';
5+
import { createLocalStorageSignal } from '../utils/localstorage';
6+
import { Option } from '../types';
7+
8+
const fluentTranslationFiles = import.meta.glob('./locale/*.ftl', {
9+
eager: true,
10+
as: 'raw',
11+
});
12+
const translationBundles = Object.fromEntries(
13+
Object.entries(fluentTranslationFiles).map(([fileName, ftl]) => {
14+
const localeName = fileName.replace(
15+
/^.*\/(.+)\..*$/,
16+
(_, locale: string) => locale,
17+
);
18+
19+
const resource = new FluentResource(ftl);
20+
const bundle = new FluentBundle(localeName);
21+
const errors = bundle.addResource(resource);
22+
if (errors.length > 0) {
23+
throw new Error(
24+
`Failed to load translation bundle ${fileName} as ${localeName}`,
25+
{ cause: errors[0] },
26+
);
27+
}
28+
29+
return [localeName, bundle];
30+
}),
31+
);
32+
33+
const FALLBACK_LOCALE = 'en';
34+
interface LanguageContext {
35+
activeLocales(): string[];
36+
setUserLocale(language: Option<string>): void;
37+
availableLocales(): Record<string, FluentBundle>;
38+
}
39+
const LanguageContext = createContext<LanguageContext>();
40+
export const requireLanguageContext = () => {
41+
const langContext = useContext(LanguageContext);
42+
if (langContext === undefined) {
43+
throw new Error(
44+
'LanguageContext is required but no LanguageProvider is currently active',
45+
);
46+
}
47+
return langContext;
48+
};
49+
50+
export const LanguageProvider = (props: { children: JSX.Element }) => {
51+
const [userLanguage, setUserLocale] =
52+
createLocalStorageSignal('user.language');
53+
const activeLocales = createMemo(() =>
54+
userLanguage().mapOrElse(
55+
() =>
56+
negotiateLanguages(
57+
navigator.languages,
58+
Object.keys(translationBundles),
59+
{
60+
defaultLocale: FALLBACK_LOCALE,
61+
},
62+
),
63+
(ul) => [ul, FALLBACK_LOCALE],
64+
),
65+
);
66+
return (
67+
<LanguageContext.Provider
68+
value={{
69+
activeLocales,
70+
setUserLocale,
71+
availableLocales: () => translationBundles,
72+
}}
73+
>
74+
{props.children}
75+
</LanguageContext.Provider>
76+
);
77+
};
78+
79+
export const translate = (
80+
translatableName: string,
81+
variables?: Record<string, FluentVariable>,
82+
options?: { overrideLocales?: string[] },
83+
) => {
84+
const context = requireLanguageContext();
85+
const localeChain = options?.overrideLocales ?? context.activeLocales();
86+
87+
const bundle = mapBundleSync(
88+
localeChain.map((locale) => translationBundles[locale]),
89+
translatableName,
90+
);
91+
if (bundle === null) {
92+
console.error(
93+
`No translation found for ${translatableName} (locale chain: ${localeChain.toString()})`,
94+
);
95+
return `#${translatableName}#`;
96+
}
97+
98+
const pattern = bundle.getMessage(translatableName)?.value;
99+
if (pattern === undefined || pattern === null) {
100+
throw new Error(
101+
`Translation pattern for ${translatableName} has no value (in bundle ${bundle.locales.toString()})`,
102+
);
103+
}
104+
105+
return bundle.formatPattern(pattern, variables);
106+
};

web/src/localization/locale/de.ftl

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
locale-name = Deutsch
2+
3+
refresh = Aktualisieren
4+
5+
stacklet--list = Stacklets
6+
stacklet--add = Stacklet hinzufügen
7+
8+
stacklet--product = Produkt
9+
stacklet--namespace = Namensraum
10+
stacklet--name = Name
11+
stacklet--status = Status
12+
13+
login--log-in = Anmelden
14+
login--log-out = Abmelden
15+
login--username = Nutzername
16+
login--password = Passwort
17+
login--logging-in = Anmelden...

web/src/localization/locale/en.ftl

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
locale-name = English
2+
3+
refresh = Refresh
4+
5+
stacklet--list = Stacklets
6+
stacklet--add = Add Stacklet
7+
8+
stacklet--product = Product
9+
stacklet--namespace = Namespace
10+
stacklet--name = Name
11+
stacklet--status = Status
12+
13+
login--log-in = Log in
14+
login--log-out = Log out
15+
login--username = Username
16+
login--password = Password
17+
login--logging-in = Logging in...

web/src/localization/locale/sv.ftl

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
locale-name = Svenska
2+
3+
refresh = Ladda om
4+
5+
stacklet--list = Stackletar
6+
stacklet--add = Lägg till Stacklet
7+
8+
stacklet--product = Produkt
9+
stacklet--namespace = Namnrymd
10+
stacklet--name = Namn
11+
stacklet--status = Status
12+
13+
login--log-in = Logga in
14+
login--log-out = Logga ut
15+
login--username = Användarnamn
16+
login--password = Lösenord
17+
login--logging-in = Loggar in...

0 commit comments

Comments
 (0)