mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-22 06:32:27 -05:00
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:
1 parent
c8b9fa753f
commit
10451a9f11
16 files changed
+274
-51
No files matched your search
+2
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user