From 65ca7eac5f3f96457f928c5227646b2fa461eb97 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:10:15 -0700 Subject: [PATCH] refactor(i18n): shared define-locale/RTL/endonym scaffolding in @hermes/shared; desktop+web forward to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop and web each re-implemented the same locale plumbing: the TranslationOverride 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.; 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. --- apps/desktop/src/i18n/context.tsx | 16 +----- apps/desktop/src/i18n/define-locale.ts | 39 +------------ apps/desktop/src/i18n/languages.ts | 14 +++-- apps/desktop/src/i18n/runtime.ts | 6 +- apps/shared/src/i18n.test.ts | 45 +++++++++++++++ apps/shared/src/i18n.ts | 80 ++++++++++++++++++++++++++ apps/shared/src/index.ts | 9 +++ web/src/i18n/context.tsx | 45 +++------------ web/src/i18n/define-locale.ts | 50 +++------------- web/tsconfig.app.json | 4 +- 10 files changed, 168 insertions(+), 140 deletions(-) create mode 100644 apps/shared/src/i18n.test.ts create mode 100644 apps/shared/src/i18n.ts diff --git a/apps/desktop/src/i18n/context.tsx b/apps/desktop/src/i18n/context.tsx index 88a02e8fd3..870b35ab1d 100644 --- a/apps/desktop/src/i18n/context.tsx +++ b/apps/desktop/src/i18n/context.tsx @@ -1,3 +1,4 @@ +import { applyDocumentLocale, isRecord } from '@hermes/shared/i18n' import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { getHermesConfigRecord, type HermesConfigRecord, saveHermesConfig } from '@/hermes' @@ -39,10 +40,6 @@ const defaultConfigClient: I18nConfigClient = { } } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - export function getConfigDisplayLanguage(config: HermesConfigRecord): unknown { return isRecord(config.display) ? config.display.language : undefined } @@ -63,17 +60,6 @@ function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)) } -const RTL_LOCALES = new Set(['ar']) - -function applyDocumentLocale(locale: Locale) { - if (typeof document === 'undefined') { - return - } - - document.documentElement.lang = locale - document.documentElement.dir = RTL_LOCALES.has(locale) ? 'rtl' : 'ltr' -} - export interface I18nContextValue { configLoadError: Error | null isLoadingConfig: boolean diff --git a/apps/desktop/src/i18n/define-locale.ts b/apps/desktop/src/i18n/define-locale.ts index bb6f29f6fb..5bedee72e4 100644 --- a/apps/desktop/src/i18n/define-locale.ts +++ b/apps/desktop/src/i18n/define-locale.ts @@ -1,41 +1,8 @@ +import { mergeTranslations, type TranslationOverride } from '@hermes/shared/i18n' + import { en } from './en' import type { Translations } from './types' -type TranslationOverride = T extends (...args: never[]) => string - ? T - : T extends readonly unknown[] - ? T - : T extends string - ? string - : T extends object - ? { [K in keyof T]?: TranslationOverride } - : T - export type TranslationOverrides = TranslationOverride -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function mergeTranslations(base: T, overrides: TranslationOverride | undefined): T { - if (!isRecord(base) || !isRecord(overrides)) { - return (overrides ?? base) as T - } - - const result: Record = { ...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(en, overrides) -} +export const defineLocale = (overrides: TranslationOverrides): Translations => mergeTranslations(en, overrides) diff --git a/apps/desktop/src/i18n/languages.ts b/apps/desktop/src/i18n/languages.ts index abd145ff8c..778a664869 100644 --- a/apps/desktop/src/i18n/languages.ts +++ b/apps/desktop/src/i18n/languages.ts @@ -1,3 +1,5 @@ +import { LOCALE_ENDONYMS } from '@hermes/shared/i18n' + import { normalize } from '@/lib/text' import type { Locale } from './types' @@ -7,37 +9,37 @@ export const DEFAULT_LOCALE: Locale = 'en' export const LOCALE_OPTIONS = [ { id: 'en', - name: 'English', + name: LOCALE_ENDONYMS.en, englishName: 'English', configValue: 'en' }, { id: 'zh', - name: '简体中文', + name: LOCALE_ENDONYMS.zh, englishName: 'Simplified Chinese', configValue: 'zh' }, { id: 'zh-hant', - name: '繁體中文', + name: LOCALE_ENDONYMS['zh-hant'], englishName: 'Traditional Chinese', configValue: 'zh-hant' }, { id: 'ja', - name: '日本語', + name: LOCALE_ENDONYMS.ja, englishName: 'Japanese', configValue: 'ja' }, { id: 'ar', - name: 'العربية', + name: LOCALE_ENDONYMS.ar, englishName: 'Arabic', configValue: 'ar' }, { id: 'ru', - name: 'Русский', + name: LOCALE_ENDONYMS.ru, englishName: 'Russian', configValue: 'ru' } diff --git a/apps/desktop/src/i18n/runtime.ts b/apps/desktop/src/i18n/runtime.ts index 71d2162c15..9dc463c031 100644 --- a/apps/desktop/src/i18n/runtime.ts +++ b/apps/desktop/src/i18n/runtime.ts @@ -1,13 +1,11 @@ +import { isRecord } from '@hermes/shared/i18n' + import { TRANSLATIONS } from './catalog' import { DEFAULT_LOCALE } from './languages' import type { Locale } from './types' let runtimeLocale: Locale = DEFAULT_LOCALE -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - /** Walk a dot-path (`a.b.c`) into a nested message tree. */ function resolvePath(source: unknown, key: string): unknown { return key.split('.').reduce((current, part) => (isRecord(current) ? current[part] : undefined), source) diff --git a/apps/shared/src/i18n.test.ts b/apps/shared/src/i18n.test.ts new file mode 100644 index 0000000000..1c0ee9077e --- /dev/null +++ b/apps/shared/src/i18n.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' + +import { applyDocumentLocale, LOCALE_ENDONYMS, mergeTranslations, RTL_LOCALES, type TranslationOverride } from './i18n' + +describe('mergeTranslations', () => { + it('keeps untouched sibling keys on a nested partial override and replaces functions/arrays wholesale', () => { + interface Catalog { + menu: { close: string; count: (n: number) => string; open: string } + steps: string[] + } + + const base: Catalog = { + menu: { open: 'Open', close: 'Close', count: n => `${n} items` }, + steps: ['one', 'two', 'three'] + } + + const overrides: TranslationOverride = { + menu: { close: 'Schließen', count: n => `${n} Einträge` }, + steps: ['eins'] + } + + const merged = mergeTranslations(base, overrides) + + expect(merged.menu.open).toBe('Open') + expect(merged.menu.close).toBe('Schließen') + expect(merged.menu.count(2)).toBe('2 Einträge') + expect(merged.steps).toEqual(['eins']) + expect(base.menu.close).toBe('Close') + }) +}) + +describe('RTL_LOCALES', () => { + it('only names locales that have an endonym', () => { + for (const locale of RTL_LOCALES) { + expect(Object.keys(LOCALE_ENDONYMS)).toContain(locale) + } + }) +}) + +describe('applyDocumentLocale', () => { + it('is a no-op without a document', () => { + expect(typeof document).toBe('undefined') + expect(() => applyDocumentLocale('ar')).not.toThrow() + }) +}) diff --git a/apps/shared/src/i18n.ts b/apps/shared/src/i18n.ts new file mode 100644 index 0000000000..690db4a895 --- /dev/null +++ b/apps/shared/src/i18n.ts @@ -0,0 +1,80 @@ +// Locale scaffolding shared by the desktop and web i18n layers. Generic over the +// translation catalog type: each app supplies its own `Translations`/`en` and +// wraps `mergeTranslations` in a one-line `defineLocale`. + +/** Partial-locale shape: every key optional, but functions/arrays are atomic and + * unknown keys still fail the type-check. */ +export type TranslationOverride = T extends (...args: never[]) => string + ? T + : T extends readonly unknown[] + ? T + : T extends string + ? string + : T extends object + ? { [K in keyof T]?: TranslationOverride } + : T + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Deep-merge a partial locale over the English base: nested records recurse, + * everything else (strings, functions, arrays) is replaced wholesale. */ +export function mergeTranslations(base: T, overrides: TranslationOverride | undefined): T { + if (!isRecord(base) || !isRecord(overrides)) { + return (overrides ?? base) as T + } + + const result: Record = { ...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 +} + +// Endonyms (native names) for the language pickers so users recognize their +// language regardless of the current UI language. No country flags: languages +// are not countries (English ≠ GB, Portuguese ≠ PT, Chinese variants ≠ any +// single jurisdiction). Desktop supports a subset of these ids; web all of them. +export const LOCALE_ENDONYMS = { + af: 'Afrikaans', + ar: 'العربية', + de: 'Deutsch', + en: 'English', + es: 'Español', + fr: 'Français', + ga: 'Gaeilge', + hu: 'Magyar', + it: 'Italiano', + ja: '日本語', + ko: '한국어', + pt: 'Português', + ru: 'Русский', + tr: 'Türkçe', + uk: 'Українська', + zh: '简体中文', + 'zh-hant': '繁體中文' +} as const satisfies Record + +export type EndonymLocale = keyof typeof LOCALE_ENDONYMS + +/** Locales whose script flows right-to-left; drives `` so Tailwind's + * logical utilities (ms-/me-, ps-/pe-) flip. */ +export const RTL_LOCALES: ReadonlySet = new Set(['ar']) + +/** Mirror the active locale onto ``. No-op without a document (SSR, tests). */ +export function applyDocumentLocale(locale: string): void { + if (typeof document === 'undefined') { + return + } + + document.documentElement.lang = locale + document.documentElement.dir = RTL_LOCALES.has(locale) ? 'rtl' : 'ltr' +} diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index b86de221b9..05449d2c95 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -49,6 +49,15 @@ export { } from './data-url-read-max' export { compactNumber } from './format' export { type FuzzyMatch, fuzzyRank, fuzzyScore, fuzzyScoreMulti, type RankedItem } from './fuzzy' +export { + applyDocumentLocale, + type EndonymLocale, + isRecord, + LOCALE_ENDONYMS, + mergeTranslations, + RTL_LOCALES, + type TranslationOverride +} from './i18n' export { type ApprovalRequestPayload, BACKEND_EVENT_NAMES, diff --git a/web/src/i18n/context.tsx b/web/src/i18n/context.tsx index e25b0cc4b5..e407988edf 100644 --- a/web/src/i18n/context.tsx +++ b/web/src/i18n/context.tsx @@ -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 = { 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(["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 = { - 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 = Object.fromEntries( + SUPPORTED_LOCALES.map((id) => [id, { name: LOCALE_ENDONYMS[id] }]), +) as Record; + 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 = { diff --git a/web/src/i18n/define-locale.ts b/web/src/i18n/define-locale.ts index 401a0cccb5..9ee2beb613 100644 --- a/web/src/i18n/define-locale.ts +++ b/web/src/i18n/define-locale.ts @@ -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 -type TranslationOverride = T extends (...args: never[]) => string - ? T - : T extends readonly unknown[] - ? T - : T extends string - ? string - : T extends object - ? { [K in keyof T]?: TranslationOverride } - : T; - -export type TranslationOverrides = TranslationOverride; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function mergeTranslations(base: T, overrides: TranslationOverride | undefined): T { - if (!isRecord(base) || !isRecord(overrides)) { - return (overrides ?? base) as T; - } - - const result: Record = { ...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(en, overrides); -} +export const defineLocale = (overrides: TranslationOverrides): Translations => + mergeTranslations(en, overrides) diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json index 5e743884fb..dd0e9c1062 100644 --- a/web/tsconfig.app.json +++ b/web/tsconfig.app.json @@ -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 */