refactor(i18n): shared define-locale/RTL/endonym scaffolding in @hermes/shared; desktop+web forward to it
Desktop and web each re-implemented the same locale plumbing: the
TranslationOverride<T> partial-catalog type, isRecord (four copies across
the two apps), mergeTranslations, the RTL_LOCALES={'ar'} set with the
documentElement.lang/dir effect, and the endonym table for the language
picker (6 entries on desktop, 17 on web, overlapping and hand-synced).
The generic parts now live once in apps/shared/src/i18n.ts (exported from
the root index and the `@hermes/shared/i18n` subpath). It is generic over
the catalog type — no Translations, no `en` — so translation catalogs stay
per-app (content decision, deliberately not merged here).
Sites (path::symbol → canonical):
apps/desktop/src/i18n/define-locale.ts::TranslationOverride, isRecord,
mergeTranslations → @hermes/shared/i18n; defineLocale is a one-liner
web/src/i18n/define-locale.ts::TranslationOverride, isRecord,
mergeTranslations → @hermes/shared/i18n; defineLocale is a one-liner
apps/desktop/src/i18n/runtime.ts::isRecord → shared isRecord
apps/desktop/src/i18n/context.tsx::isRecord, RTL_LOCALES,
applyDocumentLocale → shared isRecord / applyDocumentLocale
web/src/i18n/context.tsx::RTL_LOCALES + inline lang/dir effect
→ shared applyDocumentLocale
web/src/i18n/context.tsx::LOCALE_META literal (17 names)
→ derived from shared LOCALE_ENDONYMS (same exported shape)
apps/desktop/src/i18n/languages.ts::LOCALE_OPTIONS.name (6 names)
→ LOCALE_ENDONYMS.<id>; englishName/configValue columns stay
The six desktop endonyms were byte-identical to web's before the move.
Tests: apps/shared/src/i18n.test.ts — mergeTranslations keeps untouched
sibling keys under a nested partial override and replaces functions/arrays
wholesale without mutating the base; RTL_LOCALES ⊆ keys(LOCALE_ENDONYMS);
applyDocumentLocale is a no-op without a document. The existing desktop
context.test.tsx RTL/lang assertions keep covering the effect.
Behavior change: none.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { applyDocumentLocale, LOCALE_ENDONYMS } from "@hermes/shared/i18n";
|
||||
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
|
||||
import type { Locale, Translations } from "./types";
|
||||
import { en } from "./en";
|
||||
@@ -38,40 +39,14 @@ const TRANSLATIONS: Record<Locale, Translations> = {
|
||||
ar,
|
||||
};
|
||||
|
||||
// Locales whose script flows right-to-left. Consumed by the provider to set the
|
||||
// document direction so Tailwind's logical utilities (ms-/me-, ps-/pe-) flip.
|
||||
const RTL_LOCALES = new Set<Locale>(["ar"]);
|
||||
|
||||
// Display metadata for the language picker — endonym (native name) so users
|
||||
// recognize their language even if they don't speak the current UI language.
|
||||
// Exposed as a constant so the LanguageSwitcher and any future settings page
|
||||
// can share the same list.
|
||||
//
|
||||
// We intentionally do NOT pair locales with country flags. Languages are not
|
||||
// countries (English ≠ GB, Portuguese ≠ PT, Spanish ≠ ES, Chinese variants ≠
|
||||
// any single jurisdiction). Endonyms are unambiguous and avoid the political
|
||||
// mismapping that flag pairings inevitably create.
|
||||
export const LOCALE_META: Record<Locale, { name: string }> = {
|
||||
en: { name: "English" },
|
||||
zh: { name: "简体中文" },
|
||||
"zh-hant": { name: "繁體中文" },
|
||||
ja: { name: "日本語" },
|
||||
de: { name: "Deutsch" },
|
||||
es: { name: "Español" },
|
||||
fr: { name: "Français" },
|
||||
tr: { name: "Türkçe" },
|
||||
uk: { name: "Українська" },
|
||||
af: { name: "Afrikaans" },
|
||||
ko: { name: "한국어" },
|
||||
it: { name: "Italiano" },
|
||||
ga: { name: "Gaeilge" },
|
||||
pt: { name: "Português" },
|
||||
ru: { name: "Русский" },
|
||||
hu: { name: "Magyar" },
|
||||
ar: { name: "العربية" },
|
||||
};
|
||||
|
||||
const SUPPORTED_LOCALES = Object.keys(TRANSLATIONS) as Locale[];
|
||||
|
||||
// Display metadata for the language picker — endonyms from @hermes/shared so the
|
||||
// desktop and web pickers can never disagree on a language's native name.
|
||||
export const LOCALE_META: Record<Locale, { name: string }> = Object.fromEntries(
|
||||
SUPPORTED_LOCALES.map((id) => [id, { name: LOCALE_ENDONYMS[id] }]),
|
||||
) as Record<Locale, { name: string }>;
|
||||
|
||||
const STORAGE_KEY = "hermes-locale";
|
||||
|
||||
function isLocale(value: string): value is Locale {
|
||||
@@ -113,9 +88,7 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.lang = locale;
|
||||
document.documentElement.dir = RTL_LOCALES.has(locale) ? "rtl" : "ltr";
|
||||
applyDocumentLocale(locale);
|
||||
}, [locale]);
|
||||
|
||||
const value: I18nContextValue = {
|
||||
|
||||
@@ -1,46 +1,12 @@
|
||||
import { en } from "./en";
|
||||
import type { Translations } from "./types";
|
||||
import { mergeTranslations, type TranslationOverride } from '@hermes/shared/i18n'
|
||||
|
||||
import { en } from './en'
|
||||
import type { Translations } from './types'
|
||||
|
||||
// Partial-locale helper: a translation file supplies only the strings it has
|
||||
// translated and every missing key falls back to English, while unknown keys
|
||||
// still fail the type-check. Mirrors the desktop app's `defineLocale` so a new
|
||||
// locale (e.g. Arabic) can land without hand-porting every future English key.
|
||||
// still fail the type-check.
|
||||
export type TranslationOverrides = TranslationOverride<Translations>
|
||||
|
||||
type TranslationOverride<T> = T extends (...args: never[]) => string
|
||||
? T
|
||||
: T extends readonly unknown[]
|
||||
? T
|
||||
: T extends string
|
||||
? string
|
||||
: T extends object
|
||||
? { [K in keyof T]?: TranslationOverride<T[K]> }
|
||||
: T;
|
||||
|
||||
export type TranslationOverrides = TranslationOverride<Translations>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function mergeTranslations<T>(base: T, overrides: TranslationOverride<T> | undefined): T {
|
||||
if (!isRecord(base) || !isRecord(overrides)) {
|
||||
return (overrides ?? base) as T;
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = { ...base };
|
||||
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseValue = result[key];
|
||||
result[key] = isRecord(baseValue) && isRecord(value) ? mergeTranslations(baseValue, value) : value;
|
||||
}
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
export function defineLocale(overrides: TranslationOverrides): Translations {
|
||||
return mergeTranslations<Translations>(en, overrides);
|
||||
}
|
||||
export const defineLocale = (overrides: TranslationOverrides): Translations =>
|
||||
mergeTranslations<Translations>(en, overrides)
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
/* Path aliases */
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@hermes/shared": ["../apps/shared/src/index.ts"]
|
||||
"@hermes/shared": ["../apps/shared/src/index.ts"],
|
||||
"@hermes/shared/ansi": ["../apps/shared/src/ansi.ts"],
|
||||
"@hermes/shared/i18n": ["../apps/shared/src/i18n.ts"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
|
||||
Reference in New Issue
Block a user