feat(i18n): add app-wide localization framework and translator guide

- Add namespace-based JSON locale structure (en/es)
- Add I18n provider/context with locale persistence and fallback logic
- Add i18n parity checker script (npm run i18n:check)
- Wire app root/title shell to translation API
- Add TRANSLATION_GUIDE.md for contributors
This commit is contained in:
webadderall
2026-03-14 19:28:11 +11:00
parent 462a136ce4
commit 0c4d4c9517
21 changed files with 389 additions and 7 deletions
+64
View File
@@ -0,0 +1,64 @@
# Translation Guide
This project uses a namespace-based i18n setup so contributors can localize safely without changing app logic.
## Locale Files
All locale files live under:
- `src/i18n/locales/en/`
- `src/i18n/locales/es/`
Each locale has the same namespace files:
- `common.json`
- `launch.json`
- `editor.json`
- `timeline.json`
- `settings.json`
- `dialogs.json`
- `shortcuts.json`
English (`en`) is the source of truth for key structure.
## Key Rules
- Keep the same key paths across locales.
- Do not rename existing keys unless coordinated with code changes.
- Add new keys to `en` first, then mirror into all other locales.
- Prefer descriptive, stable keys. Example: `app.editorTitle`.
- Interpolation is supported with `{{name}}` style placeholders.
## How Translation Is Read
- Keys with a namespace prefix like `settings.export.title` use that namespace.
- Keys without a namespace default to `common`.
- Missing translations fall back to English, then to the provided fallback string, then to the key.
## Validate Locale Structure
Run:
```bash
npm run i18n:check
```
This checks for:
- Missing namespace files
- Missing keys compared to `en`
- Extra keys not present in `en`
## Contributor Workflow
1. Pull latest `main`.
2. Update `en/<namespace>.json` with new keys if needed.
3. Add matching keys to other locale files.
4. Run `npm run i18n:check`.
5. Run app locally (`npm run dev`) and spot-check UI text.
6. Open PR with a short summary of changed namespaces.
## Scope Notes
Current framework is app-wide and ready for full localization rollout.
Not every UI string is migrated yet. Migration should be done incrementally by namespace to keep PRs reviewable and low-risk.
+1
View File
@@ -27,6 +27,7 @@
"build:mac": "npm run build:native-helpers && tsc && vite build && electron-builder --mac",
"build:win": "tsc && vite build && electron-builder --win",
"build:linux": "tsc && vite build && electron-builder --linux",
"i18n:check": "node scripts/i18n-check.mjs",
"test": "vitest --run",
"test:watch": "vitest"
},
+86
View File
@@ -0,0 +1,86 @@
import fs from 'node:fs'
import path from 'node:path'
const root = process.cwd()
const localesDir = path.join(root, 'src', 'i18n', 'locales')
const locales = fs.readdirSync(localesDir).filter((entry) => {
const fullPath = path.join(localesDir, entry)
return fs.statSync(fullPath).isDirectory()
})
if (!locales.includes('en')) {
console.error('i18n-check: expected base locale directory "en"')
process.exit(1)
}
function loadJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
}
function collectKeyPaths(obj, prefix = '') {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
return prefix ? [prefix] : []
}
const keys = Object.keys(obj)
if (keys.length === 0) {
return prefix ? [prefix] : []
}
const paths = []
for (const key of keys) {
const nextPrefix = prefix ? `${prefix}.${key}` : key
const value = obj[key]
if (value && typeof value === 'object' && !Array.isArray(value)) {
paths.push(...collectKeyPaths(value, nextPrefix))
} else {
paths.push(nextPrefix)
}
}
return paths
}
const baseLocaleDir = path.join(localesDir, 'en')
const namespaceFiles = fs.readdirSync(baseLocaleDir).filter((file) => file.endsWith('.json'))
let hasErrors = false
for (const namespaceFile of namespaceFiles) {
const baseData = loadJson(path.join(baseLocaleDir, namespaceFile))
const baseKeys = new Set(collectKeyPaths(baseData))
for (const locale of locales) {
if (locale === 'en') continue
const localeFile = path.join(localesDir, locale, namespaceFile)
if (!fs.existsSync(localeFile)) {
console.error(`i18n-check: missing namespace file ${locale}/${namespaceFile}`)
hasErrors = true
continue
}
const localeData = loadJson(localeFile)
const localeKeys = new Set(collectKeyPaths(localeData))
for (const key of baseKeys) {
if (!localeKeys.has(key)) {
console.error(`i18n-check: missing key ${locale}/${namespaceFile}:${key}`)
hasErrors = true
}
}
for (const key of localeKeys) {
if (!baseKeys.has(key)) {
console.error(`i18n-check: extra key ${locale}/${namespaceFile}:${key}`)
hasErrors = true
}
}
}
}
if (hasErrors) {
process.exit(1)
}
console.log('i18n-check: locale files are structurally consistent')
+12 -5
View File
@@ -5,15 +5,16 @@ import VideoEditor from "./components/video-editor/VideoEditor";
import { loadAllCustomFonts } from "./lib/customFonts";
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
import { useI18n } from "./contexts/I18nContext";
export default function App() {
const [windowType, setWindowType] = useState('');
const { locale, t } = useI18n();
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const type = params.get('windowType') || '';
setWindowType(type);
document.title = type === 'editor' ? 'Recordly Editor' : 'Recordly';
if (type === 'hud-overlay' || type === 'source-selector') {
document.body.style.background = 'transparent';
@@ -27,6 +28,12 @@ export default function App() {
});
}, []);
useEffect(() => {
document.title = windowType === 'editor'
? t('app.editorTitle', 'Recordly Editor')
: t('app.name', 'Recordly');
}, [windowType, locale, t]);
switch (windowType) {
case 'hud-overlay':
return <LaunchWindow />;
@@ -43,14 +50,14 @@ export default function App() {
return (
<div className="flex h-full w-full items-center justify-center bg-slate-950 text-white">
<div className="flex items-center gap-4 rounded-2xl border border-white/10 bg-white/5 px-6 py-5 shadow-2xl shadow-black/30 backdrop-blur-xl">
<img src="/app-icons/recordly-128.png" alt="Recordly" className="h-12 w-12 rounded-xl" />
<img src="/app-icons/recordly-128.png" alt={t('app.name', 'Recordly')} className="h-12 w-12 rounded-xl" />
<div>
<h1 className="text-xl font-semibold tracking-tight">Recordly</h1>
<p className="text-sm text-white/65">Screen recording and editing</p>
<h1 className="text-xl font-semibold tracking-tight">{t('app.name', 'Recordly')}</h1>
<p className="text-sm text-white/65">{t('app.subtitle', 'Screen recording and editing')}</p>
</div>
</div>
</div>
);
}
}
+179
View File
@@ -0,0 +1,179 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
import {
DEFAULT_LOCALE,
I18N_NAMESPACES,
SUPPORTED_LOCALES,
type AppLocale,
type I18nNamespace,
} from '@/i18n/config'
import enCommon from '@/i18n/locales/en/common.json'
import enDialogs from '@/i18n/locales/en/dialogs.json'
import enEditor from '@/i18n/locales/en/editor.json'
import enLaunch from '@/i18n/locales/en/launch.json'
import enSettings from '@/i18n/locales/en/settings.json'
import enShortcuts from '@/i18n/locales/en/shortcuts.json'
import enTimeline from '@/i18n/locales/en/timeline.json'
import esCommon from '@/i18n/locales/es/common.json'
import esDialogs from '@/i18n/locales/es/dialogs.json'
import esEditor from '@/i18n/locales/es/editor.json'
import esLaunch from '@/i18n/locales/es/launch.json'
import esSettings from '@/i18n/locales/es/settings.json'
import esShortcuts from '@/i18n/locales/es/shortcuts.json'
import esTimeline from '@/i18n/locales/es/timeline.json'
const LOCALE_STORAGE_KEY = 'recordly.locale'
type LocaleBundle = Record<I18nNamespace, Record<string, unknown>>
const messages: Record<AppLocale, LocaleBundle> = {
en: {
common: enCommon,
launch: enLaunch,
editor: enEditor,
timeline: enTimeline,
settings: enSettings,
dialogs: enDialogs,
shortcuts: enShortcuts,
},
es: {
common: esCommon,
launch: esLaunch,
editor: esEditor,
timeline: esTimeline,
settings: esSettings,
dialogs: esDialogs,
shortcuts: esShortcuts,
},
} as const
interface I18nContextValue {
locale: AppLocale
setLocale: (locale: AppLocale) => void
t: (key: string, fallback?: string, vars?: Record<string, string | number>) => string
}
const I18nContext = createContext<I18nContextValue | null>(null)
function isSupportedLocale(locale: string): locale is AppLocale {
return SUPPORTED_LOCALES.includes(locale as AppLocale)
}
function normalizeLocale(locale: string | null | undefined): AppLocale {
if (!locale) {
return DEFAULT_LOCALE
}
const normalized = locale.toLowerCase().split('-')[0]
return isSupportedLocale(normalized) ? normalized : DEFAULT_LOCALE
}
function getInitialLocale(): AppLocale {
if (typeof window === 'undefined') {
return DEFAULT_LOCALE
}
const storedLocale = window.localStorage.getItem(LOCALE_STORAGE_KEY)
if (storedLocale && isSupportedLocale(storedLocale)) {
return storedLocale
}
return normalizeLocale(window.navigator.language)
}
function getMessageValue(source: unknown, key: string): string | undefined {
const parts = key.split('.')
let current: unknown = source
for (const part of parts) {
if (!current || typeof current !== 'object' || !(part in current)) {
return undefined
}
current = (current as Record<string, unknown>)[part]
}
return typeof current === 'string' ? current : undefined
}
function interpolate(template: string, vars?: Record<string, string | number>) {
if (!vars) return template
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_match, key) => {
const value = vars[key]
return value === undefined ? '' : String(value)
})
}
function parseKey(key: string): { namespace: I18nNamespace; path: string } {
const [first, ...rest] = key.split('.')
if (I18N_NAMESPACES.includes(first as I18nNamespace) && rest.length > 0) {
return { namespace: first as I18nNamespace, path: rest.join('.') }
}
return { namespace: 'common', path: key }
}
function translateForLocale(
locale: AppLocale,
key: string,
fallback?: string,
vars?: Record<string, string | number>,
) {
const { namespace, path } = parseKey(key)
const rawValue =
getMessageValue(messages[locale][namespace], path)
?? getMessageValue(messages[DEFAULT_LOCALE][namespace], path)
?? fallback
?? key
return interpolate(rawValue, vars)
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<AppLocale>(getInitialLocale)
const setLocale = useCallback((nextLocale: AppLocale) => {
setLocaleState(nextLocale)
if (typeof window !== 'undefined') {
window.localStorage.setItem(LOCALE_STORAGE_KEY, nextLocale)
}
}, [])
useEffect(() => {
document.documentElement.lang = locale
}, [locale])
const t = useCallback((key: string, fallback?: string, vars?: Record<string, string | number>) => {
return translateForLocale(locale, key, fallback, vars)
}, [locale])
const value = useMemo<I18nContextValue>(() => ({
locale,
setLocale,
t,
}), [locale, setLocale, t])
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
}
export function useI18n() {
const context = useContext(I18nContext)
if (!context) {
throw new Error('useI18n must be used within <I18nProvider>')
}
return context
}
export function useScopedT(namespace: I18nNamespace) {
const { t } = useI18n()
return useCallback((key: string, fallback?: string, vars?: Record<string, string | number>) => {
return t(`${namespace}.${key}`, fallback, vars)
}, [namespace, t])
}
+16
View File
@@ -0,0 +1,16 @@
export const DEFAULT_LOCALE = 'en' as const
export const SUPPORTED_LOCALES = ['en', 'es'] as const
export const I18N_NAMESPACES = [
'common',
'launch',
'editor',
'timeline',
'settings',
'dialogs',
'shortcuts',
] as const
export type AppLocale = (typeof SUPPORTED_LOCALES)[number]
export type I18nNamespace = (typeof I18N_NAMESPACES)[number]
+7
View File
@@ -0,0 +1,7 @@
{
"app": {
"name": "Recordly",
"editorTitle": "Recordly Editor",
"subtitle": "Screen recording and editing"
}
}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+7
View File
@@ -0,0 +1,7 @@
{
"app": {
"name": "Recordly",
"editorTitle": "Editor de Recordly",
"subtitle": "Grabacion de pantalla y edicion"
}
}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+5 -2
View File
@@ -1,13 +1,16 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import { I18nProvider } from './contexts/I18nContext.tsx'
import './index.css'
document.documentElement.dataset.platform = /mac/i.test(navigator.platform) ? 'macos' : 'other'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
<I18nProvider>
<App />
</I18nProvider>
</React.StrictMode>,
)