Add i18n support #5
@ -1 +1,2 @@
|
||||
src/lib/api.d.ts
|
||||
src/i18n/locales/*/*.ts
|
||||
15
frontend/.swcrc
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://swc.rs/schema.json",
|
||||
"jsc": {
|
||||
"experimental": {
|
||||
"plugins": [
|
||||
[
|
||||
"@lingui/swc-plugin",
|
||||
{
|
||||
// Additional Configuration
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,63 @@
|
||||
|
||||
## Developing
|
||||
|
||||
## i18n
|
||||
|
||||
This project uses [Lingui](https://lingui.js.org/) for internalization. The translations are stored in the `src/i18n/locales` folder.
|
||||
|
||||
**Translating in JSX: `<Trans>` Component**
|
||||
|
||||
Use the `<Trans>` component when translating strings directly in JSX markup:
|
||||
|
||||
```jsx
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
function Greeting() {
|
||||
return (
|
||||
<h1>
|
||||
<Trans>Hello, world!</Trans>
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Translating in JavaScript: `i18n._(t\`...`)`**
|
||||
|
||||
For translations used **outside of JSX**, such as in variables, logs, or logic, use `i18n._(t\`...`)`:
|
||||
|
||||
Example:
|
||||
|
||||
```jsx
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { i18n } from "@lingui/core";
|
||||
|
||||
console.log(i18n._(t`Operation completed successfully.`));
|
||||
```
|
||||
|
||||
### Extracting & Compiling Translations
|
||||
|
||||
#### Extract Strings
|
||||
|
||||
To extract translatable strings into `.po` files:
|
||||
|
||||
```bash
|
||||
# To extract all translations
|
||||
npm run extract
|
||||
|
||||
# To extract translations and clean up unused ones
|
||||
npm run extract-clean
|
||||
```
|
||||
|
||||
This updates the `messages.po` files inside `src/i18n/locales/{locale}/`.
|
||||
|
||||
#### Compile Translations
|
||||
|
||||
After updating the `.po` files with your translations, compile them into `.ts` files:
|
||||
|
||||
```bash
|
||||
npm run compile
|
||||
```
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node v20
|
||||
|
||||
@ -6,7 +6,7 @@ import tseslint from "typescript-eslint";
|
||||
import eslintConfigPrettier from "eslint-config-prettier/flat";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist"] },
|
||||
{ ignores: ["dist", "src/i18n/locales/**/*.ts"] },
|
||||
|
ansv7779 marked this conversation as resolved
|
||||
{
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
|
||||
12
frontend/lingui.config.js
Normal file
@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "@lingui/cli";
|
||||
|
||||
export default defineConfig({
|
||||
sourceLocale: "en",
|
||||
locales: ["sv", "en"],
|
||||
catalogs: [
|
||||
{
|
||||
path: "<rootDir>/src/i18n/locales/{locale}/messages",
|
||||
include: ["src"],
|
||||
},
|
||||
],
|
||||
});
|
||||
3626
frontend/package-lock.json
generated
@ -5,13 +5,17 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "lingui compile --typescript && tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier . --write",
|
||||
"update-api": "openapi-typescript http://localhost:8080/v3/api-docs --output src/lib/api.d.ts",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"extract": "lingui extract",
|
||||
"extract-clean": "lingui extract --clean",
|
||||
"compile": "lingui compile --typescript"
|
||||
|
stne3960 marked this conversation as resolved
Outdated
ansv7779
commented
Need instructions in the readme what these commands do and what the expected workflow is when adding/translating new content. Need instructions in the readme what these commands do and what the expected workflow is when adding/translating new content.
|
||||
},
|
||||
"dependencies": {
|
||||
"@lingui/react": "^5.3.0",
|
||||
"openapi-fetch": "^0.13.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@ -19,6 +23,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@lingui/cli": "^5.3.0",
|
||||
"@lingui/swc-plugin": "^5.5.1",
|
||||
"@lingui/vite-plugin": "^5.3.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react-swc": "^3.8.0",
|
||||
|
||||
@ -3,16 +3,23 @@ import suLogoLandscape from "./assets/SU_logo_optimized.svg";
|
||||
import { ProfileContext } from "./hooks/profile.ts";
|
||||
import Studentportalen from "./Studentportalen.tsx";
|
||||
import { useViewTransitioningFetch } from "./hooks/fetch";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useEffect } from "react";
|
||||
import { dynamicActivate } from "./i18n/dynamicActivate.ts";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { i18n } from "@lingui/core";
|
||||
|
||||
function App() {
|
||||
const { data: profile, error } = useViewTransitioningFetch("/profile");
|
||||
|
||||
if (!profile) {
|
||||
return splashScreen("Loading...");
|
||||
}
|
||||
if (error) {
|
||||
return splashScreen("Application failed to start");
|
||||
}
|
||||
useEffect(() => {
|
||||
if (profile?.language) {
|
||||
dynamicActivate(profile.language);
|
||||
}
|
||||
}, [profile?.language]);
|
||||
|
||||
if (!profile) return splashScreen(i18n._(t`Loading...`));
|
||||
if (error) return splashScreen(i18n._(t`Application failed to start`));
|
||||
|
||||
return (
|
||||
<ProfileContext value={profile}>
|
||||
@ -23,12 +30,16 @@ function App() {
|
||||
|
||||
function splashScreen(extraContent: string) {
|
||||
return (
|
||||
<div id={"app"}>
|
||||
<img src={suLogoLandscape} alt={"Stockholm University"} />
|
||||
<div id="app">
|
||||
<img src={suLogoLandscape} alt={i18n._(t`Stockholm University`)} />
|
||||
<div>
|
||||
<h1>Student portal</h1>
|
||||
<h2>Department of Computer and Systems Sciences</h2>
|
||||
{extraContent}
|
||||
<h1>
|
||||
<Trans>Student portal</Trans>
|
||||
</h1>
|
||||
<h2>
|
||||
<Trans>Department of Computer and Systems Sciences</Trans>
|
||||
</h2>
|
||||
<p>{extraContent}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
12
frontend/src/i18n/dynamicActivate.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { i18n } from "@lingui/core";
|
||||
|
||||
/**
|
||||
* We do a dynamic import of just the catalog that we need.
|
||||
* Source: https://lingui.dev/guides/dynamic-loading-catalogs
|
||||
* @param locale any locale string
|
||||
*/
|
||||
export async function dynamicActivate(locale: string) {
|
||||
const { messages } = await import(`./locales/${locale}/messages.po`);
|
||||
i18n.load(locale, messages);
|
||||
i18n.activate(locale);
|
||||
}
|
||||
15
frontend/src/i18n/getDefaultLocale.ts
Normal file
@ -0,0 +1,15 @@
|
||||
const supportedLocales = ["sv", "en"];
|
||||
|
||||
export function getDefaultLocale(): string {
|
||||
const userPreferredLangs = navigator.languages.map(
|
||||
|
stne3960 marked this conversation as resolved
Outdated
ansv7779
commented
Should maybe iterate over Also should we maybe default to English if no available language is found instead of Swedish? Should maybe iterate over `navigator.languages` instead to find the most preferred locale? If a user sends "Accept-Language: de-DE, en-US, sv-SE" they will get Swedish instead of English since German isn't available.
Also should we maybe default to English if no available language is found instead of Swedish?
stne3960
commented
I agree that we should interate over Regarding defaulting to english if no language is found, very good question. We are Swedish public agency, most outward facing systems default to Swedish, but on the other hand, if they don't have Swedish as an accepted language, there's probably a high likelyhood that they are non-Swedish speakers. I think there's a strong case here to default to English. I agree that we should interate over `navigator.languages` instead.
Regarding defaulting to english if no language is found, very good question. We are Swedish public agency, most outward facing systems default to Swedish, but on the other hand, if they don't have Swedish as an accepted language, there's probably a high likelyhood that they are non-Swedish speakers. I think there's a strong case here to default to English.
ansv7779
commented
Based on that I'd say defaulting to English makes sense and adhering to the "Accept-Language" header is just nice for users. - https://www.su.se defaults to Swedish no matter what even if sending "Accept-Language: en-US".
- https://scipro.dsv.su.se is only available in English.
- https://daisy.dsv.su.se defaults to English and adheres to the "Accept-Language" header.
- https://nextilearn.dsv.su.se defaults to English and seems to ignore the "Accept-Language" header.
Based on that I'd say defaulting to English makes sense and adhering to the "Accept-Language" header is just nice for users.
|
||||
(lang) => lang.split("-")[0],
|
||||
);
|
||||
|
||||
for (const lang of userPreferredLangs) {
|
||||
if (supportedLocales.includes(lang)) {
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
|
||||
return "en"; // fallback to English
|
||||
}
|
||||
55
frontend/src/i18n/locales/en/messages.po
Normal file
@ -0,0 +1,55 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2025-04-07 19:53+0200\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: en\n"
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/App.tsx:22
|
||||
msgid "Application failed to start"
|
||||
msgstr "Application failed to start"
|
||||
|
||||
#: src/App.tsx:40
|
||||
msgid "Department of Computer and Systems Sciences"
|
||||
msgstr "Department of Computer and Systems Sciences"
|
||||
|
||||
#: src/studentportalen/Home.tsx:10
|
||||
msgid "Here you can see the latest and greatest"
|
||||
msgstr "Here you can see the latest and greatest"
|
||||
|
||||
#: src/studentportalen/Menu.tsx:12
|
||||
msgid "Home"
|
||||
msgstr "Home"
|
||||
|
||||
#: src/studentportalen/Home.tsx:7
|
||||
msgid "Home screen"
|
||||
msgstr "Home screen"
|
||||
|
||||
#: src/App.tsx:21
|
||||
msgid "Loading..."
|
||||
msgstr "Loading..."
|
||||
|
||||
#: src/studentportalen/Menu.tsx:24
|
||||
msgid "Log out"
|
||||
msgstr "Log out"
|
||||
|
||||
#: src/studentportalen/Menu.tsx:21
|
||||
msgid "Profile"
|
||||
msgstr "Profile"
|
||||
|
||||
#: src/App.tsx:34
|
||||
#: src/studentportalen/Header.tsx:12
|
||||
msgid "Stockholm University"
|
||||
msgstr "Stockholm University"
|
||||
|
||||
#: src/App.tsx:37
|
||||
msgid "Student portal"
|
||||
msgstr "Student portal"
|
||||
1
frontend/src/i18n/locales/en/messages.ts
Normal file
@ -0,0 +1 @@
|
||||
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"3C3CY2\":[\"Application failed to start\"],\"xYQvrP\":[\"Department of Computer and Systems Sciences\"],\"z13C6e\":[\"Here you can see the latest and greatest\"],\"i0qMbr\":[\"Home\"],\"p7H6+B\":[\"Home screen\"],\"Z3FXyt\":[\"Loading...\"],\"FgAxTj\":[\"Log out\"],\"vERlcd\":[\"Profile\"],\"he8WSY\":[\"Stockholm University\"],\"07DTL2\":[\"Student portal\"]}")as Messages;
|
||||
55
frontend/src/i18n/locales/sv/messages.po
Normal file
@ -0,0 +1,55 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"POT-Creation-Date: 2025-04-07 19:53+0200\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"X-Generator: @lingui/cli\n"
|
||||
"Language: sv\n"
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#: src/App.tsx:22
|
||||
msgid "Application failed to start"
|
||||
msgstr "Applikationen startade inte"
|
||||
|
||||
#: src/App.tsx:40
|
||||
msgid "Department of Computer and Systems Sciences"
|
||||
msgstr "Institutionen för data- och systemvetenskap"
|
||||
|
||||
#: src/studentportalen/Home.tsx:10
|
||||
msgid "Here you can see the latest and greatest"
|
||||
msgstr "Här kan du hitta det senaste och bästa"
|
||||
|
||||
#: src/studentportalen/Menu.tsx:12
|
||||
msgid "Home"
|
||||
msgstr "Hem"
|
||||
|
||||
#: src/studentportalen/Home.tsx:7
|
||||
msgid "Home screen"
|
||||
msgstr "Hemskärm"
|
||||
|
||||
#: src/App.tsx:21
|
||||
msgid "Loading..."
|
||||
msgstr "Laddar..."
|
||||
|
||||
#: src/studentportalen/Menu.tsx:24
|
||||
msgid "Log out"
|
||||
msgstr "Logga ut"
|
||||
|
||||
#: src/studentportalen/Menu.tsx:21
|
||||
msgid "Profile"
|
||||
msgstr "Profil"
|
||||
|
||||
#: src/App.tsx:34
|
||||
#: src/studentportalen/Header.tsx:12
|
||||
msgid "Stockholm University"
|
||||
msgstr "Stockholms universitet"
|
||||
|
||||
#: src/App.tsx:37
|
||||
msgid "Student portal"
|
||||
msgstr "Studentportal"
|
||||
1
frontend/src/i18n/locales/sv/messages.ts
Normal file
@ -0,0 +1 @@
|
||||
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"3C3CY2\":[\"Applikationen startade inte\"],\"xYQvrP\":[\"Institutionen för data- och systemvetenskap\"],\"z13C6e\":[\"Här kan du hitta det senaste och bästa\"],\"i0qMbr\":[\"Hem\"],\"p7H6+B\":[\"Hemskärm\"],\"Z3FXyt\":[\"Laddar...\"],\"FgAxTj\":[\"Logga ut\"],\"vERlcd\":[\"Profil\"],\"he8WSY\":[\"Stockholms universitet\"],\"07DTL2\":[\"Studentportal\"]}")as Messages;
|
||||
@ -2,9 +2,19 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { dynamicActivate } from "./i18n/dynamicActivate.ts";
|
||||
import { getDefaultLocale } from "./i18n/getDefaultLocale.ts";
|
||||
|
||||
// Activate the default locale from browser settings before a profile is loaded
|
||||
const locale = getDefaultLocale();
|
||||
await dynamicActivate(locale);
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<I18nProvider i18n={i18n}>
|
||||
<App />
|
||||
</I18nProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
import "./header.css";
|
||||
import suLogo from "../assets/SU_logo_optimized.svg";
|
||||
import { useProfile } from "../hooks/profile.ts";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { i18n } from "@lingui/core";
|
||||
|
||||
export default function Header() {
|
||||
const profile = useProfile();
|
||||
|
||||
return (
|
||||
<header>
|
||||
<img src={suLogo} alt="Stockholm University" />
|
||||
<img src={suLogo} alt={i18n._(t`Stockholm University`)} />
|
||||
|
||||
<span>{profile.name}</span>
|
||||
</header>
|
||||
);
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<h1>Home screen</h1>
|
||||
<p>Here you can see the latest and greatest</p>
|
||||
<h1>
|
||||
<Trans>Home screen</Trans>
|
||||
</h1>
|
||||
<p>
|
||||
<Trans>Here you can see the latest and greatest</Trans>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,21 +1,28 @@
|
||||
import { NavLink } from "react-router";
|
||||
import "./menu.css";
|
||||
import { useState } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
export default function Menu() {
|
||||
const [subMenuVisible, setSubMenuVisible] = useState(false);
|
||||
return (
|
||||
<menu className={"main"}>
|
||||
<li>
|
||||
<NavLink to={"/"}>Home</NavLink>
|
||||
<NavLink to={"/"}>
|
||||
<Trans>Home</Trans>
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<button onClick={() => setSubMenuVisible((current) => !current)}>
|
||||
<span>| | |</span>
|
||||
</button>
|
||||
<menu className={"sub " + (subMenuVisible ? "visible" : "")}>
|
||||
<li>Profile</li>
|
||||
<li>Log out</li>
|
||||
<li>
|
||||
<Trans>Profile</Trans>
|
||||
</li>
|
||||
<li>
|
||||
<Trans>Log out</Trans>
|
||||
</li>
|
||||
</menu>
|
||||
</li>
|
||||
</menu>
|
||||
|
||||
@ -1,7 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import { lingui } from "@lingui/vite-plugin";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
target: "esnext",
|
||||
},
|
||||
plugins: [
|
||||
react({
|
||||
plugins: [["@lingui/swc-plugin", {}]],
|
||||
}),
|
||||
lingui(),
|
||||
],
|
||||
});
|
||||
|
||||
Do we need this since the generated
.tsfiles have/*eslint-disable*/at the start?Otherwise you get the warning
Unused eslint-disable directive (no problems were reported)when runnningnpm run lint