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, 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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