feat(i18n): add a language by dropping in its locale file

Languages come from the locale files, named in themselves via Intl.DisplayNames, with missing keys falling back to English. The week start follows the locale, the Appearance picker switches to a Select past four languages, and docs/TRANSLATING.md plus an i18n:check script guide translators.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-20 07:25:46 +02:00
1 parent c8b9fa753f
commit 10451a9f11
16 files changed
+274 -51

No files matched your search

+2 -1
View File
@@ -9,7 +9,8 @@ Thanks for taking the time. Bug reports, fixes and features are all welcome.
first so we can agree on the shape before you write it. It's no fun to have a large PR turned
down over a design decision that could have been a two-message conversation.
Getting the project running locally is covered in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
Getting the project running locally is covered in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). To translate
Thingport into your language, see [docs/TRANSLATING.md](docs/TRANSLATING.md) -- it needs no code.
## Workflow
+62
View File
@@ -0,0 +1,62 @@
# Translating Thingport
Thingport's interface is translated with one JSON file per language in
[frontend/src/i18n/locales/](../frontend/src/i18n/locales/). Add a file and the language appears in
**Configuration → Appearance** and in the <kbd>!language</kbd> search command, named in its own language. There's
nothing to register or wire up.
A translation doesn't have to be finished to be useful: any text not translated yet shows in English, so you can
send a partial translation and fill it in later.
## Add a language
1. Copy [`en.json`](../frontend/src/i18n/locales/en.json) to a file named after the language's
[code](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes): `es.json` for Spanish, `fr.json` for French.
Add a region only for a regional variant that differs enough to need its own file, as in `pt-BR.json`.
2. Translate the values, the text on the right of each `:`. Leave the keys on the left as they are.
3. Open a pull request against `develop`, as in [CONTRIBUTING.md](../CONTRIBUTING.md). Its title can be
`feat(i18n): add Spanish translation`.
You can do all of this in the browser: open `en.json` on GitHub, copy its contents, then use **Add file → Create new
file** in the `locales` folder, and GitHub offers to fork the repository and open the pull request for you.
To improve an existing language, edit its file the same way; the commit type is then `fix(i18n):`.
## What to keep as it is
- **Placeholders** in double braces, such as `{{count}}` or `{{name}}`, are filled in by Thingport. Keep them,
spelled exactly the same, but move them to wherever the sentence needs them.
- **Names** of Thingport, Thingport Grab and the model sites (MakerWorld, Printables, Thingiverse and so on) stay
as they are.
## Plurals
Text that depends on a number has one key per plural form, ending in `_one`, `_few`, `_many` or `_other`:
```json
"minutesAgo_one": "{{count}} minute ago",
"minutesAgo_other": "{{count}} minutes ago"
```
Use the forms your language needs, which aren't always English's: Lithuanian, for example, adds `_few`. Check your
language in the [Unicode plural rules](https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html)
and add or drop forms to match.
## Check your translation
With the [development setup](DEVELOPMENT.md) running, pick your language under **Configuration → Appearance**
and look through the app; the choice is remembered by your browser.
To list what's left to translate, run:
```bash
npm --prefix frontend run i18n:check es
```
It prints how much of the language is done, each key that's still missing, and any key that English no longer
uses (which you can delete). Without a code, it checks every language.
## Keeping up
New features add English text first. Translations catch up whenever someone has time, and until then the new text
shows in English. If you'd like to look after a language, say so in your pull request, so we know who to ask.
+1 -1
View File
@@ -1,7 +1,7 @@
# Themes and languages
- **Light and dark themes**, or **System** to follow your device's setting.
- **English and Lithuanian.**
- **English and Lithuanian**, and any language someone has [translated it into](../TRANSLATING.md).
Choose them under **Configuration → Appearance**, from the user menu, or type <kbd>!</kbd> in the search bar for the
theme and language commands. The theme is saved to your account, so it follows you to other devices. The language is
+1
View File
@@ -12,6 +12,7 @@
"test": "vitest",
"test:run": "vitest run --passWithNoTests",
"lint": "oxlint .",
"i18n:check": "node scripts/i18n-check.mjs",
"verify": "npm run typecheck && npm run lint && npm run test:run && npm run build"
},
"dependencies": {
+35
View File
@@ -0,0 +1,35 @@
// Lists each locale's keys missing from, or no longer in, en.json: `npm run i18n:check [code]`.
// Plural forms (key_one, key_few, ...) count as one key, since languages need different ones.
import { readdirSync, readFileSync } from "node:fs";
const dir = new URL("../src/i18n/locales/", import.meta.url);
const PLURAL = /_(zero|one|two|few|many|other)$/;
function keys(node, prefix = "") {
return Object.entries(node).flatMap(([key, value]) =>
value && typeof value === "object" ? keys(value, `${prefix}${key}.`) : [`${prefix}${key}`.replace(PLURAL, "")],
);
}
const read = (code) => new Set(keys(JSON.parse(readFileSync(new URL(`${code}.json`, dir), "utf8"))));
const en = read("en");
const only = process.argv[2];
const codes = readdirSync(dir)
.filter((file) => file.endsWith(".json") && file !== "en.json")
.map((file) => file.slice(0, -".json".length))
.filter((code) => !only || code === only);
if (only && codes.length === 0) {
console.error(`No src/i18n/locales/${only}.json`);
process.exit(1);
}
for (const code of codes) {
const locale = read(code);
const missing = [...en].filter((key) => !locale.has(key));
const stale = [...locale].filter((key) => !en.has(key));
const done = Math.floor(((en.size - missing.length) / en.size) * 100);
console.log(`${code}: ${done}% translated, ${missing.length} missing, ${stale.length} no longer used`);
for (const key of missing) console.log(` missing ${key}`);
for (const key of stale) console.log(` unused ${key}`);
}
@@ -1,10 +1,12 @@
import React from "react";
import { useTranslation } from "react-i18next";
import MenuItem from "@mui/material/MenuItem";
import Select from "@mui/material/Select";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { UnauthorizedError } from "../../../api/client";
import { settingsApi } from "../../../api/settings";
import { SUPPORTED_LANGUAGES, type LanguageCode } from "../../../constants/languages";
import { matchLanguage, SUPPORTED_LANGUAGES } from "../../../constants/languages";
import type { ThemeSelection } from "../../../constants/settingsOptions";
import { setCachedAuthorPreviewEnabled, useAuthorPreviewEnabled } from "../../../hooks/useAuthorPreviewEnabled";
import Segmented from "../../controls/Segmented";
@@ -12,6 +14,8 @@ import HelpTip from "../../HelpTip";
import { PanelHeader, SectionLabel } from "./parts";
const THEMES: ThemeSelection[] = ["light", "dark", "system"];
// More languages than this no longer fit a segmented control comfortably.
const MAX_SEGMENTED_LANGUAGES = 4;
type Props = {
theme: ThemeSelection;
@@ -24,8 +28,7 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
const { t, i18n } = useTranslation("app");
const authorPreview = useAuthorPreviewEnabled();
const [authorPreviewError, setAuthorPreviewError] = React.useState<string | null>(null);
const language = (SUPPORTED_LANGUAGES.find((lang) => (i18n.resolvedLanguage ?? i18n.language).startsWith(lang.code))
?.code ?? "en") as LanguageCode;
const language = matchLanguage(i18n.resolvedLanguage ?? i18n.language);
const toggleAuthorPreview = async (enabled: boolean) => {
setAuthorPreviewError(null);
@@ -56,12 +59,28 @@ export default function AppearancePanel({ theme, onThemeChange, onUnauthorized }
</div>
<div>
<SectionLabel>{t("profile.language.heading")}</SectionLabel>
<Segmented
label={t("profile.language.heading")}
value={language}
onChange={(code) => void i18n.changeLanguage(code)}
options={SUPPORTED_LANGUAGES.map((lang) => ({ value: lang.code, label: lang.label }))}
/>
{SUPPORTED_LANGUAGES.length > MAX_SEGMENTED_LANGUAGES ? (
<Select
size="small"
value={language}
onChange={(e) => void i18n.changeLanguage(e.target.value)}
inputProps={{ "aria-label": t("profile.language.heading") }}
sx={{ minWidth: 200 }}
>
{SUPPORTED_LANGUAGES.map((lang) => (
<MenuItem key={lang.code} value={lang.code} lang={lang.code}>
{lang.label}
</MenuItem>
))}
</Select>
) : (
<Segmented
label={t("profile.language.heading")}
value={language}
onChange={(code) => void i18n.changeLanguage(code)}
options={SUPPORTED_LANGUAGES.map((lang) => ({ value: lang.code, label: lang.label }))}
/>
)}
</div>
<div>
<SectionLabel>
@@ -44,7 +44,7 @@ import { UnauthorizedError } from "../../../api/client";
import { type SearchResult, searchApi } from "../../../api/search";
import { printsApi } from "../../../api/prints";
import { IMPORT_PROVIDER_INFO } from "../../../constants/importProviders";
import { SUPPORTED_LANGUAGES, type LanguageCode } from "../../../constants/languages";
import { SUPPORTED_LANGUAGES } from "../../../constants/languages";
import { detectImportProvider } from "../../../utils/importLinkDetection";
import type { ThemeSelection } from "../../../constants/settingsOptions";
import { useDebouncedValue } from "../../../hooks/useDebouncedValue";
@@ -129,12 +129,6 @@ function pageFor(entry: RecentEntry, pages: SearchPage[] = SEARCH_PAGES): Search
return entry.kind === "page" ? pages.find((page) => page.path === entry.path) : undefined;
}
// Also matched: "!language lith", "!language lietuvių".
const LANGUAGE_ALIASES: Record<LanguageCode, string> = {
en: "english",
lt: "lithuanian lietuvių",
};
// "auto" also matches "system".
const THEMES: { theme: ThemeSelection; alias?: string; keywords: string[]; icon: ReactNode }[] = [
{ theme: "dark", keywords: ["dark", "dark mode", "night"], icon: <DarkModeOutlinedIcon /> },
@@ -327,14 +321,15 @@ export default function GlobalSearch({
icon,
run: () => onThemeChange(target),
})),
...SUPPORTED_LANGUAGES.map(({ code, label }) => ({
// Also matched by either name: "!language lith", "!language lietuvių".
...SUPPORTED_LANGUAGES.map(({ code, label, englishName }) => ({
id: `language-${code}`,
bang: "language",
arg: code,
argAlias: LANGUAGE_ALIASES[code],
argAlias: [englishName, label].join(" ").toLowerCase(),
// Named in itself, as in the profile's picker, so it's findable from any UI language.
label: t("app:search.commands.language", { name: label }),
keywords: ["language", "kalba", ...LANGUAGE_ALIASES[code].split(" ")],
keywords: ["language", "kalba", ...`${englishName} ${label}`.toLowerCase().split(" ")],
icon: <TranslateIcon />,
run: () => void i18n.changeLanguage(code),
})),
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { LOCALES, matchLanguage, SUPPORTED_LANGUAGES } from "./languages";
describe("languages", () => {
it("offers every locale file, English first, each named in itself", () => {
expect(SUPPORTED_LANGUAGES.map((l) => l.code).toSorted()).toEqual(Object.keys(LOCALES).toSorted());
expect(SUPPORTED_LANGUAGES[0].code).toBe("en");
expect(SUPPORTED_LANGUAGES.find((l) => l.code === "lt")).toMatchObject({
label: "Lietuvių",
englishName: "Lithuanian",
});
});
it("matches a regional or unknown language to a supported one", () => {
expect(matchLanguage("lt")).toBe("lt");
expect(matchLanguage("lt-LT")).toBe("lt");
expect(matchLanguage("xx")).toBe("en");
expect(matchLanguage(undefined)).toBe("en");
});
it("keeps every locale to the known namespaces", () => {
const unknown = Object.entries(LOCALES).flatMap(([code, locale]) =>
Object.keys(locale)
.filter((ns) => !["common", "app", "library", "models"].includes(ns))
.map((ns) => `${code}.${ns}`),
);
expect(unknown).toEqual([]);
});
});
+40 -5
View File
@@ -1,8 +1,43 @@
export const SUPPORTED_LANGUAGES = [
{ code: "en", label: "English" },
{ code: "lt", label: "Lietuvių" },
] as const;
export type Locale = Record<string, Record<string, unknown>>;
export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]["code"];
/** Every `i18n/locales/<code>.json`, keyed by code: dropping in a file adds the language. */
export const LOCALES: Record<string, Locale> = Object.fromEntries(
Object.entries(import.meta.glob<Locale>("../i18n/locales/*.json", { eager: true, import: "default" })).map(
([path, locale]) => [path.slice(path.lastIndexOf("/") + 1, -".json".length), locale],
),
);
export const FALLBACK_LANGUAGE = "en";
function displayName(code: string, inLanguage: string): string {
try {
const name = new Intl.DisplayNames([inLanguage], { type: "language" }).of(code) ?? code;
return name.charAt(0).toLocaleUpperCase(inLanguage) + name.slice(1);
} catch {
return code;
}
}
export type Language = {
code: string;
/** Named in itself ("Lietuvių"), so it's recognisable from any UI language. */
label: string;
/** Named in English, for search. */
englishName: string;
};
// English first, the rest by their own name.
export const SUPPORTED_LANGUAGES: Language[] = Object.keys(LOCALES)
.map((code) => ({ code, label: displayName(code, code), englishName: displayName(code, "en") }))
.toSorted((a, b) =>
a.code === FALLBACK_LANGUAGE ? -1 : b.code === FALLBACK_LANGUAGE ? 1 : a.label.localeCompare(b.label),
);
/** The supported language for an i18next language ("pt-BR" falls back to "pt"). */
export function matchLanguage(lng: string | undefined): string {
if (!lng) return FALLBACK_LANGUAGE;
const codes = SUPPORTED_LANGUAGES.map((l) => l.code);
return codes.find((c) => c === lng) ?? codes.find((c) => c === lng.split("-")[0]) ?? FALLBACK_LANGUAGE;
}
export const LANGUAGE_STORAGE_KEY = "thingport_language";
+13 -10
View File
@@ -2,23 +2,26 @@ import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
import en from "./locales/en.json";
import lt from "./locales/lt.json";
import { LANGUAGE_STORAGE_KEY, SUPPORTED_LANGUAGES } from "../constants/languages";
import { FALLBACK_LANGUAGE, LANGUAGE_STORAGE_KEY, LOCALES, SUPPORTED_LANGUAGES } from "../constants/languages";
// Namespaces by area: "common" (generic words), "app" (shell chrome, modals), "library" (viewer
// overlays and previews), "models" (Models page, model detail, author page).
// overlays and previews), "models" (Models page, model detail, author page). A locale may be partial:
// missing keys fall back to English.
const NAMESPACES = ["common", "app", "library", "models"];
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { common: en.common, app: en.app, library: en.library, models: en.models },
lt: { common: lt.common, app: lt.app, library: lt.library, models: lt.models },
},
ns: ["common", "app", "library", "models"],
resources: Object.fromEntries(
Object.entries(LOCALES).map(([code, locale]) => [
code,
Object.fromEntries(NAMESPACES.filter((ns) => locale[ns]).map((ns) => [ns, locale[ns]])),
]),
),
ns: NAMESPACES,
defaultNS: "common",
fallbackLng: "en",
fallbackLng: FALLBACK_LANGUAGE,
supportedLngs: SUPPORTED_LANGUAGES.map((l) => l.code),
interpolation: { escapeValue: false },
detection: {
@@ -8,7 +8,7 @@ import InsightsIcon from "@mui/icons-material/Insights";
import { activityApi, type ActivityDay } from "../../api/activity";
import { UnauthorizedError } from "../../api/client";
import Heatmap from "../ProfilePage/activity/Heatmap";
import { addDays, localToday, weekday, type DateRange } from "../ProfilePage/activity/calendar";
import { addDays, localToday, weekday, weekStartFor, type DateRange } from "../ProfilePage/activity/calendar";
// The heatmap's cell and gap, and room for its weekday labels.
const WEEK_WIDTH = 14;
@@ -29,7 +29,7 @@ export default function ActivityHeatmapCard({ onUnauthorized }: Props) {
const { t, i18n } = useTranslation("app");
const navigate = useNavigate();
const today = localToday();
const weekStart = i18n.language.startsWith("lt") ? 1 : 0;
const weekStart = weekStartFor(i18n.language);
const [days, setDays] = useState<ActivityDay[] | null>(null);
const [failed, setFailed] = useState(false);
const [weeks, setWeeks] = useState<number | null>(null);
@@ -6,7 +6,7 @@ import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import { alpha, type Theme } from "@mui/material/styles";
import type { ActivityDay, ActivityKind } from "../../../api/activity";
import { buildWeeks, level, monthStarts, type DateRange } from "./calendar";
import { buildWeeks, level, monthStarts, weekStartFor, type DateRange } from "./calendar";
const CELL = 11;
const GAP = 3;
@@ -33,7 +33,7 @@ type Props = {
export default function Heatmap({ range, days, selectedDay, today, onSelectDay }: Props) {
const { t, i18n } = useTranslation("app");
// Monday-first in Lithuanian, as its calendars are; Sunday-first otherwise, as GitHub's is.
const weekStart = i18n.language.startsWith("lt") ? 1 : 0;
const weekStart = weekStartFor(i18n.language);
const weeks = React.useMemo(() => buildWeeks(range, weekStart), [range, weekStart]);
const months = React.useMemo(() => monthStarts(weeks), [weeks]);
const byDate = React.useMemo(() => new Map(days.map((day) => [day.date, day.counts])), [days]);
@@ -1,5 +1,15 @@
import { describe, expect, it } from "vitest";
import { addDays, buildWeeks, lastYearRange, level, localToday, monthStarts, weekday, yearRange } from "./calendar";
import {
addDays,
buildWeeks,
lastYearRange,
level,
localToday,
monthStarts,
weekday,
weekStartFor,
yearRange,
} from "./calendar";
describe("ranges", () => {
it("makes the last year end today and start the day after a year ago", () => {
@@ -69,3 +79,11 @@ describe("level", () => {
expect(level(10, 10)).toBe(4);
});
});
describe("weekStartFor", () => {
it("follows the language's locale", () => {
expect(weekStartFor("en")).toBe(0);
expect(weekStartFor("lt")).toBe(1);
expect(weekStartFor("de")).toBe(1);
});
});
@@ -11,6 +11,21 @@ function toIso(date: Date): string {
return date.toISOString().slice(0, 10);
}
type WeekInfoLocale = Intl.Locale & { getWeekInfo?: () => { firstDay: number }; weekInfo?: { firstDay: number } };
/** Sunday (0) or Monday (1), as the language's locale starts its week; a Saturday start is shown as Monday. */
export function weekStartFor(language: string): 0 | 1 {
try {
const locale = new Intl.Locale(language) as WeekInfoLocale;
const firstDay = (locale.getWeekInfo?.() ?? locale.weekInfo)?.firstDay;
if (firstDay) return firstDay === 7 ? 0 : 1;
} catch {
// An unknown tag falls through to the guess below.
}
// Without week info (Firefox), only English keeps the US Sunday start.
return language.startsWith("en") ? 0 : 1;
}
export function addDays(iso: string, days: number): string {
return toIso(new Date(toDate(iso).getTime() + days * DAY_MS));
}
+12 -10
View File
@@ -1,5 +1,6 @@
import type { i18n as I18n } from "i18next";
import type { Category } from "../api/categories";
import { FALLBACK_LANGUAGE, matchLanguage } from "../constants/languages";
export type CategoryDisplayText = {
name: string;
@@ -16,12 +17,12 @@ function firstMakerworldCatId(category: Category): number | null {
return Number.isFinite(id) ? id : null;
}
/** Both locales are always loaded, so English is available as reference text. */
/** All locales are always loaded, so English is available as reference text. */
function defaultCategoryText(i18n: I18n, lng: string, id: number): DefaultCategoryText | undefined {
return i18n.getResourceBundle(lng, "models")?.defaultCategories?.[id];
}
/** Shows the Lithuanian text for a starter category field only while it still matches the original
/** Shows the UI language's text for a starter category field only while it still matches the original
* English verbatim (i.e. the user hasn't edited it). Each field is checked independently. */
export function translateCategoryDisplay(category: Category, i18n: I18n): CategoryDisplayText {
const stored: CategoryDisplayText = {
@@ -30,18 +31,19 @@ export function translateCategoryDisplay(category: Category, i18n: I18n): Catego
metaDescription: category.meta_description,
};
const lang = i18n.language.slice(0, 2).toLowerCase();
if (lang !== "lt") return stored;
const lang = matchLanguage(i18n.language);
if (lang === FALLBACK_LANGUAGE) return stored;
const id = firstMakerworldCatId(category);
if (id === null) return stored;
const en = defaultCategoryText(i18n, "en", id);
const lt = defaultCategoryText(i18n, "lt", id);
if (!en || !lt) return stored;
const en = defaultCategoryText(i18n, FALLBACK_LANGUAGE, id);
const local = defaultCategoryText(i18n, lang, id);
if (!en || !local) return stored;
return {
name: category.name === en.name ? lt.name : category.name,
metaTitle: category.meta_title === en.metaTitle ? lt.metaTitle : category.meta_title,
metaDescription: category.meta_description === en.metaDescription ? lt.metaDescription : category.meta_description,
name: category.name === en.name ? local.name : category.name,
metaTitle: category.meta_title === en.metaTitle ? local.metaTitle : category.meta_title,
metaDescription:
category.meta_description === en.metaDescription ? local.metaDescription : category.meta_description,
};
}
+8
View File
@@ -394,6 +394,14 @@ export const DOCS = [
nav: "Development",
description: "Run Thingport locally with hot reload, and run the test suites.",
},
{
slug: "translating",
source: "docs/TRANSLATING.md",
group: "Contributing",
title: "Translating Thingport",
nav: "Translating",
description: "Add a language or improve one by translating a single JSON file; it appears in the app on its own.",
},
{
slug: "grab/development",
source: "extension/CONTRIBUTING.md",