65ca7eac5f
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.
81 lines
2.7 KiB
TypeScript
81 lines
2.7 KiB
TypeScript
// 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'
|
|
}
|