Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 78 additions & 35 deletions src/i18n/bundles.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,78 @@
type LocaleModule = { default: Record<string, unknown> };

const localeOf = (path: string) => {
const segments = path.split("/");
return segments[segments.length - 2];
};

export function assembleLocales(
glob: Record<string, LocaleModule>,
): Record<string, Record<string, unknown>> {
const out: Record<string, Record<string, unknown>> = {};
for (const [path, mod] of Object.entries(glob)) {
const bundle = (out[localeOf(path)] ??= {});
for (const [k, v] of Object.entries(mod.default)) {
bundle[k] = { ...(bundle[k] as object), ...(v as object) };
}
}
return out;
}

export const englishBundle = assembleLocales(
import.meta.glob("./locales/en/*.json", { eager: true }) as Record<string, LocaleModule>,
).en;

const otherLocales = import.meta.glob(["./locales/*/*.json", "!./locales/en/*.json"]) as Record<
string,
() => Promise<LocaleModule>
>;

export async function loadLocaleBundle(locale: string): Promise<Record<string, unknown> | undefined> {
const files = Object.entries(otherLocales).filter(([path]) => localeOf(path) === locale);
if (files.length === 0) return undefined;
const modules = await Promise.all(files.map(async ([path, load]) => [path, await load()] as const));
return assembleLocales(Object.fromEntries(modules))[locale];
}
type LocaleModule = { default: Record<string, unknown> };

const localeOf = (path: string) => {
const segments = path.split("/");
return segments[segments.length - 2];
};

function declaredFallback(bundle: Record<string, unknown>): string | undefined {
const meta = bundle._meta;
return meta && typeof meta === "object" ? (meta as { fallback?: string }).fallback : undefined;
}

export function assembleLocales(
glob: Record<string, LocaleModule>,
): Record<string, Record<string, unknown>> {
const raw: Record<string, Record<string, unknown>> = {};
for (const [path, mod] of Object.entries(glob)) {
const bundle = (raw[localeOf(path)] ??= {});
for (const [k, v] of Object.entries(mod.default)) {
bundle[k] = { ...(bundle[k] as object), ...(v as object) };
}
}

// A locale such as pt-BR may ship only a _meta.json declaring `fallback: "pt"`.
// Resolve those chains so its bundle carries the base language's strings.
const resolved: Record<string, Record<string, unknown>> = {};
const resolving = new Set<string>();
const resolve = (locale: string): Record<string, unknown> => {
const cached = resolved[locale];
if (cached) return cached;
const bundle = raw[locale];
if (!bundle) return {};
const fallback = declaredFallback(bundle);
if (!fallback || fallback === locale || resolving.has(locale) || !raw[fallback]) {
resolved[locale] = bundle;
return bundle;
}
resolving.add(locale);
const merged = { ...resolve(fallback), ...bundle };
resolving.delete(locale);
resolved[locale] = merged;
return merged;
};

return Object.fromEntries(Object.keys(raw).map((locale) => [locale, resolve(locale)]));
}

export const englishBundle = assembleLocales(
import.meta.glob("./locales/en/*.json", { eager: true }) as Record<string, LocaleModule>,
).en;

const otherLocales = import.meta.glob(["./locales/*/*.json", "!./locales/en/*.json"]) as Record<
string,
() => Promise<LocaleModule>
>;

/** Walks `_meta.fallback` from `locale` so a regional bundle also loads its base language. */
async function localeChain(locale: string): Promise<Set<string>> {
const chain = new Set([locale]);
let current = locale;
for (;;) {
const load = otherLocales[`./locales/${current}/_meta.json`];
if (!load) break;
const fallback = declaredFallback((await load()).default);
if (!fallback || chain.has(fallback)) break;
chain.add(fallback);
current = fallback;
}
return chain;
}

export async function loadLocaleBundle(locale: string): Promise<Record<string, unknown> | undefined> {
const chain = await localeChain(locale);
const files = Object.entries(otherLocales).filter(([path]) => chain.has(localeOf(path)));
if (files.length === 0) return undefined;
const modules = await Promise.all(files.map(async ([path, load]) => [path, await load()] as const));
return assembleLocales(Object.fromEntries(modules))[locale];
}
142 changes: 71 additions & 71 deletions src/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,71 @@
import i18n from "i18next";
import { initReactI18next, useTranslation } from "react-i18next";
import { useLocaleStore } from "@/stores/localeStore";
import { englishBundle, loadLocaleBundle } from "./bundles";

// No async backend: i18n.changeLanguage() mutates i18n.language synchronously, which
// non-component callers (e.g. getSettingsNav()) rely on. Only a locale not loaded yet switches late.
i18n.use(initReactI18next).init({
resources: { en: { translation: englishBundle } },
lng: useLocaleStore.getState().locale,
fallbackLng: "en",
interpolation: { escapeValue: false },
returnNull: false,
});

const loaded = new Set<string>(["en"]);
const loading = new Map<string, Promise<void>>();

/** Adds a locale's strings to i18n; English ships in the main bundle, the rest load on demand. */
export function ensureLocale(locale: string): Promise<void> {
if (loaded.has(locale)) return Promise.resolve();
let pending = loading.get(locale);
if (!pending) {
pending = loadLocaleBundle(locale)
.then((bundle) => {
if (bundle) i18n.addResourceBundle(locale, "translation", bundle, true, false);
loaded.add(locale);
})
.catch(() => {})
.finally(() => loading.delete(locale));
loading.set(locale, pending);
}
return pending;
}

/** Resolves once the persisted locale's strings are in, so the first render is already translated. */
export const i18nReady = ensureLocale(useLocaleStore.getState().locale);

function followLocaleSetting() {
const { locale } = useLocaleStore.getState();
if (i18n.language !== locale) i18n.changeLanguage(locale);
document.documentElement.lang = locale;
}

useLocaleStore.subscribe((state) => {
if (loaded.has(state.locale)) followLocaleSetting();
else void ensureLocale(state.locale).then(followLocaleSetting);
});

/** The language the UI strings are in; trails the locale setting while its strings load. */
export function useUiLanguage(): string {
return (useTranslation().i18n ?? i18n).language;
}
document.documentElement.lang = useLocaleStore.getState().locale;

/** A label that translates on every read, plus its English text for search. */
export interface LazyLabel {
(): string;
en: () => string;
}

/** For UI definitions that live outside components (command lists,
* registries): resolve the label at render, never at module load, so it
* follows the app language. */
export function lazyT(key: string, options?: Record<string, unknown>): LazyLabel {
return Object.assign(() => i18n.t(key, options), {
en: () => i18n.t(key, { ...options, lng: "en" }),
});
}

export default i18n;
import i18n from "i18next";
import { initReactI18next, useTranslation } from "react-i18next";
import { useLocaleStore } from "@/stores/localeStore";
import { englishBundle, loadLocaleBundle } from "./bundles";
// No async backend: i18n.changeLanguage() mutates i18n.language synchronously, which
// non-component callers (e.g. getSettingsNav()) rely on. Only a locale not loaded yet switches late.
i18n.use(initReactI18next).init({
resources: { en: { translation: englishBundle } },
lng: useLocaleStore.getState().locale,
fallbackLng: { "pt-BR": ["pt", "en"], "pt-PT": ["pt", "en"], default: ["en"] },
interpolation: { escapeValue: false },
returnNull: false,
});
const loaded = new Set<string>(["en"]);
const loading = new Map<string, Promise<void>>();
/** Adds a locale's strings to i18n; English ships in the main bundle, the rest load on demand. */
export function ensureLocale(locale: string): Promise<void> {
if (loaded.has(locale)) return Promise.resolve();
let pending = loading.get(locale);
if (!pending) {
pending = loadLocaleBundle(locale)
.then((bundle) => {
if (bundle) i18n.addResourceBundle(locale, "translation", bundle, true, false);
loaded.add(locale);
})
.catch(() => {})
.finally(() => loading.delete(locale));
loading.set(locale, pending);
}
return pending;
}
/** Resolves once the persisted locale's strings are in, so the first render is already translated. */
export const i18nReady = ensureLocale(useLocaleStore.getState().locale);
function followLocaleSetting() {
const { locale } = useLocaleStore.getState();
if (i18n.language !== locale) i18n.changeLanguage(locale);
document.documentElement.lang = locale;
}
useLocaleStore.subscribe((state) => {
if (loaded.has(state.locale)) followLocaleSetting();
else void ensureLocale(state.locale).then(followLocaleSetting);
});
/** The language the UI strings are in; trails the locale setting while its strings load. */
export function useUiLanguage(): string {
return (useTranslation().i18n ?? i18n).language;
}
document.documentElement.lang = useLocaleStore.getState().locale;
/** A label that translates on every read, plus its English text for search. */
export interface LazyLabel {
(): string;
en: () => string;
}
/** For UI definitions that live outside components (command lists,
* registries): resolve the label at render, never at module load, so it
* follows the app language. */
export function lazyT(key: string, options?: Record<string, unknown>): LazyLabel {
return Object.assign(() => i18n.t(key, options), {
en: () => i18n.t(key, { ...options, lng: "en" }),
});
}
export default i18n;
6 changes: 6 additions & 0 deletions src/i18n/locales/pt-BR/_meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"_meta": {
"status": "native-speaker contribution (pt-BR); inherits machine-drafted pt baseline — pt-BR-specific overrides welcome",
"fallback": "pt"
}
}
6 changes: 6 additions & 0 deletions src/i18n/locales/pt-PT/_meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"_meta": {
"status": "machine-generated from pt; native-speaker review welcome",
"fallback": "pt"
}
}
22 changes: 22 additions & 0 deletions src/i18n/locales/pt-PT/changelog.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"changelog": {
"title": "Novidades",
"autoDownload": "Download automático",
"quickLinks": {
"documentation": "Documentação"
},
"loading": "A carregar changelog…",
"unavailable": "Changelog indisponível off-line. Reconecte para ver as novidades.",
"hidePreviousReleases": "Ocultar versões anteriores",
"previousReleases": "Versões anteriores ({{count}})",
"newBadge": "Novo",
"updateBanner": {
"checking": "A verificar atualizações…",
"downloading": "A transferir v{{version}}… {{progress}}%",
"ready": "v{{version}} está pronto para instalar",
"restartToUpdate": "Reiniciar para atualizar",
"available": "v{{version}} está disponível",
"download": "Baixar"
}
}
}
Loading
Loading