Add i18n support #5

Closed
stne3960 wants to merge 13 commits from basic-scaffolding-frontend-lingui into basic-scaffolding-frontend
19 changed files with 3524 additions and 424 deletions

View File

@ -1 +1,2 @@
src/lib/api.d.ts src/lib/api.d.ts
src/i18n/locales/*/*.ts

15
frontend/.swcrc Normal file
View File

@ -0,0 +1,15 @@
{
"$schema": "https://swc.rs/schema.json",
"jsc": {
"experimental": {
"plugins": [
[
"@lingui/swc-plugin",
{
// Additional Configuration
}
]
]
}
}
}

View File

@ -2,6 +2,63 @@
## Developing ## 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 ### Prerequisites
- Node v20 - Node v20

View File

@ -6,7 +6,7 @@ import tseslint from "typescript-eslint";
import eslintConfigPrettier from "eslint-config-prettier/flat"; import eslintConfigPrettier from "eslint-config-prettier/flat";
export default tseslint.config( export default tseslint.config(
{ ignores: ["dist"] }, { ignores: ["dist", "src/i18n/locales/**/*.ts"] },
ansv7779 marked this conversation as resolved
Review

Do we need this since the generated .ts files have /*eslint-disable*/ at the start?

Do we need this since the generated `.ts` files have `/*eslint-disable*/` at the start?
Review

Otherwise you get the warning Unused eslint-disable directive (no problems were reported) when runnning npm run lint

Otherwise you get the warning `Unused eslint-disable directive (no problems were reported)` when runnning `npm run lint`
{ {
extends: [ extends: [
js.configs.recommended, js.configs.recommended,

12
frontend/lingui.config.js Normal file
View 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"],
},
],
});

File diff suppressed because it is too large Load Diff

View File

@ -5,13 +5,17 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "lingui compile --typescript && tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier . --write", "format": "prettier . --write",
"update-api": "openapi-typescript http://localhost:8080/v3/api-docs --output src/lib/api.d.ts", "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

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": { "dependencies": {
"@lingui/react": "^5.3.0",
"openapi-fetch": "^0.13.5", "openapi-fetch": "^0.13.5",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@ -19,6 +23,9 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.21.0", "@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": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@vitejs/plugin-react-swc": "^3.8.0", "@vitejs/plugin-react-swc": "^3.8.0",

View File

@ -3,16 +3,23 @@ import suLogoLandscape from "./assets/SU_logo_optimized.svg";
import { ProfileContext } from "./hooks/profile.ts"; import { ProfileContext } from "./hooks/profile.ts";
import Studentportalen from "./Studentportalen.tsx"; import Studentportalen from "./Studentportalen.tsx";
import { useViewTransitioningFetch } from "./hooks/fetch"; 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() { function App() {
const { data: profile, error } = useViewTransitioningFetch("/profile"); const { data: profile, error } = useViewTransitioningFetch("/profile");
if (!profile) { useEffect(() => {
return splashScreen("Loading..."); if (profile?.language) {
} dynamicActivate(profile.language);
if (error) { }
return splashScreen("Application failed to start"); }, [profile?.language]);
}
if (!profile) return splashScreen(i18n._(t`Loading...`));
if (error) return splashScreen(i18n._(t`Application failed to start`));
return ( return (
<ProfileContext value={profile}> <ProfileContext value={profile}>
@ -23,12 +30,16 @@ function App() {
function splashScreen(extraContent: string) { function splashScreen(extraContent: string) {
return ( return (
<div id={"app"}> <div id="app">
<img src={suLogoLandscape} alt={"Stockholm University"} /> <img src={suLogoLandscape} alt={i18n._(t`Stockholm University`)} />
<div> <div>
<h1>Student portal</h1> <h1>
<h2>Department of Computer and Systems Sciences</h2> <Trans>Student portal</Trans>
{extraContent} </h1>
<h2>
<Trans>Department of Computer and Systems Sciences</Trans>
</h2>
<p>{extraContent}</p>
</div> </div>
</div> </div>
); );

View 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);
}

View 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

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?

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?

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.

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.

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
}

View 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"

View 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;

View 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"

View 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;

View File

@ -2,9 +2,19 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "./index.css"; import "./index.css";
import App from "./App.tsx"; 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( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> <I18nProvider i18n={i18n}>
<App />
</I18nProvider>
</StrictMode>, </StrictMode>,
); );

View File

@ -1,13 +1,16 @@
import "./header.css"; import "./header.css";
import suLogo from "../assets/SU_logo_optimized.svg"; import suLogo from "../assets/SU_logo_optimized.svg";
import { useProfile } from "../hooks/profile.ts"; import { useProfile } from "../hooks/profile.ts";
import { t } from "@lingui/core/macro";
import { i18n } from "@lingui/core";
export default function Header() { export default function Header() {
const profile = useProfile(); const profile = useProfile();
return ( return (
<header> <header>
<img src={suLogo} alt="Stockholm University" /> <img src={suLogo} alt={i18n._(t`Stockholm University`)} />
<span>{profile.name}</span> <span>{profile.name}</span>
</header> </header>
); );

View File

@ -1,8 +1,14 @@
import { Trans } from "@lingui/react/macro";
export default function Home() { export default function Home() {
return ( return (
<> <>
<h1>Home screen</h1> <h1>
<p>Here you can see the latest and greatest</p> <Trans>Home screen</Trans>
</h1>
<p>
<Trans>Here you can see the latest and greatest</Trans>
</p>
</> </>
); );
} }

View File

@ -1,21 +1,28 @@
import { NavLink } from "react-router"; import { NavLink } from "react-router";
import "./menu.css"; import "./menu.css";
import { useState } from "react"; import { useState } from "react";
import { Trans } from "@lingui/react/macro";
export default function Menu() { export default function Menu() {
const [subMenuVisible, setSubMenuVisible] = useState(false); const [subMenuVisible, setSubMenuVisible] = useState(false);
return ( return (
<menu className={"main"}> <menu className={"main"}>
<li> <li>
<NavLink to={"/"}>Home</NavLink> <NavLink to={"/"}>
<Trans>Home</Trans>
</NavLink>
</li> </li>
<li> <li>
<button onClick={() => setSubMenuVisible((current) => !current)}> <button onClick={() => setSubMenuVisible((current) => !current)}>
<span>| | |</span> <span>| | |</span>
</button> </button>
<menu className={"sub " + (subMenuVisible ? "visible" : "")}> <menu className={"sub " + (subMenuVisible ? "visible" : "")}>
<li>Profile</li> <li>
<li>Log out</li> <Trans>Profile</Trans>
</li>
<li>
<Trans>Log out</Trans>
</li>
</menu> </menu>
</li> </li>
</menu> </menu>

View File

@ -1,7 +1,15 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc"; import react from "@vitejs/plugin-react-swc";
import { lingui } from "@lingui/vite-plugin";
// https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], build: {
target: "esnext",
},
plugins: [
react({
plugins: [["@lingui/swc-plugin", {}]],
}),
lingui(),
],
}); });