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
+1 -15
View File
@@ -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<string, unknown> {
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<Locale>(['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
+3 -36
View File
@@ -1,41 +1,8 @@
import { mergeTranslations, type TranslationOverride } from '@hermes/shared/i18n'
import { en } from './en'
import type { Translations } from './types'
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)
+8 -6
View File
@@ -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'
}
+2 -4
View File
@@ -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<string, unknown> {
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<unknown>((current, part) => (isRecord(current) ? current[part] : undefined), source)
+45
View File
@@ -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<Catalog> = {
menu: { close: 'Schließen', count: n => `${n} Einträge` },
steps: ['eins']
}
const merged = mergeTranslations<Catalog>(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()
})
})
+80
View File
@@ -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> = 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 function isRecord(value: unknown): value is Record<string, unknown> {
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<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
}
// 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<string, string>
export type EndonymLocale = keyof typeof LOCALE_ENDONYMS
/** Locales whose script flows right-to-left; drives `<html dir>` so Tailwind's
* logical utilities (ms-/me-, ps-/pe-) flip. */
export const RTL_LOCALES: ReadonlySet<string> = new Set<EndonymLocale>(['ar'])
/** Mirror the active locale onto `<html lang dir>`. 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'
}
+9
View File
@@ -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,
+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 */