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:
teknium1
2026-09-12 20:10:15 -07:00
committed by Teknium
parent a3d259019b
commit 65ca7eac5f
10 changed files with 168 additions and 140 deletions
+9 -36
View File
@@ -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 = {
+8 -42
View File
@@ -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)
+3 -1
View File
@@ -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 */