From 0c4d4c9517d902e1190dc6f0e253e6f839907e03 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sat, 14 Mar 2026 18:48:42 +1100 Subject: [PATCH] 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 --- TRANSLATION_GUIDE.md | 64 +++++++++++ package.json | 1 + scripts/i18n-check.mjs | 86 ++++++++++++++ src/App.tsx | 17 ++- src/contexts/I18nContext.tsx | 179 +++++++++++++++++++++++++++++ src/i18n/config.ts | 16 +++ src/i18n/locales/en/common.json | 7 ++ src/i18n/locales/en/dialogs.json | 1 + src/i18n/locales/en/editor.json | 1 + src/i18n/locales/en/launch.json | 1 + src/i18n/locales/en/settings.json | 1 + src/i18n/locales/en/shortcuts.json | 1 + src/i18n/locales/en/timeline.json | 1 + src/i18n/locales/es/common.json | 7 ++ src/i18n/locales/es/dialogs.json | 1 + src/i18n/locales/es/editor.json | 1 + src/i18n/locales/es/launch.json | 1 + src/i18n/locales/es/settings.json | 1 + src/i18n/locales/es/shortcuts.json | 1 + src/i18n/locales/es/timeline.json | 1 + src/main.tsx | 7 +- 21 files changed, 389 insertions(+), 7 deletions(-) create mode 100644 TRANSLATION_GUIDE.md create mode 100644 scripts/i18n-check.mjs create mode 100644 src/contexts/I18nContext.tsx create mode 100644 src/i18n/config.ts create mode 100644 src/i18n/locales/en/common.json create mode 100644 src/i18n/locales/en/dialogs.json create mode 100644 src/i18n/locales/en/editor.json create mode 100644 src/i18n/locales/en/launch.json create mode 100644 src/i18n/locales/en/settings.json create mode 100644 src/i18n/locales/en/shortcuts.json create mode 100644 src/i18n/locales/en/timeline.json create mode 100644 src/i18n/locales/es/common.json create mode 100644 src/i18n/locales/es/dialogs.json create mode 100644 src/i18n/locales/es/editor.json create mode 100644 src/i18n/locales/es/launch.json create mode 100644 src/i18n/locales/es/settings.json create mode 100644 src/i18n/locales/es/shortcuts.json create mode 100644 src/i18n/locales/es/timeline.json diff --git a/TRANSLATION_GUIDE.md b/TRANSLATION_GUIDE.md new file mode 100644 index 00000000..ef494308 --- /dev/null +++ b/TRANSLATION_GUIDE.md @@ -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/.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. diff --git a/package.json b/package.json index 750b7ada..e38f3062 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/i18n-check.mjs b/scripts/i18n-check.mjs new file mode 100644 index 00000000..19535bda --- /dev/null +++ b/scripts/i18n-check.mjs @@ -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') \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 75474fca..2a471f1f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ; @@ -43,14 +50,14 @@ export default function App() { return (
- Recordly + {t('app.name',
-

Recordly

-

Screen recording and editing

+

{t('app.name', 'Recordly')}

+

{t('app.subtitle', 'Screen recording and editing')}

); } } - + diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx new file mode 100644 index 00000000..bfe50fe3 --- /dev/null +++ b/src/contexts/I18nContext.tsx @@ -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> + +const messages: Record = { + 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 +} + +const I18nContext = createContext(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)[part] + } + + return typeof current === 'string' ? current : undefined +} + +function interpolate(template: string, vars?: Record) { + 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, +) { + 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(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) => { + return translateForLocale(locale, key, fallback, vars) + }, [locale]) + + const value = useMemo(() => ({ + locale, + setLocale, + t, + }), [locale, setLocale, t]) + + return {children} +} + +export function useI18n() { + const context = useContext(I18nContext) + if (!context) { + throw new Error('useI18n must be used within ') + } + return context +} + +export function useScopedT(namespace: I18nNamespace) { + const { t } = useI18n() + return useCallback((key: string, fallback?: string, vars?: Record) => { + return t(`${namespace}.${key}`, fallback, vars) + }, [namespace, t]) +} \ No newline at end of file diff --git a/src/i18n/config.ts b/src/i18n/config.ts new file mode 100644 index 00000000..b01deaaa --- /dev/null +++ b/src/i18n/config.ts @@ -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] \ No newline at end of file diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json new file mode 100644 index 00000000..42341541 --- /dev/null +++ b/src/i18n/locales/en/common.json @@ -0,0 +1,7 @@ +{ + "app": { + "name": "Recordly", + "editorTitle": "Recordly Editor", + "subtitle": "Screen recording and editing" + } +} \ No newline at end of file diff --git a/src/i18n/locales/en/dialogs.json b/src/i18n/locales/en/dialogs.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/i18n/locales/en/dialogs.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/i18n/locales/en/editor.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/i18n/locales/en/launch.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/i18n/locales/en/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/i18n/locales/en/shortcuts.json b/src/i18n/locales/en/shortcuts.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/i18n/locales/en/shortcuts.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/i18n/locales/en/timeline.json b/src/i18n/locales/en/timeline.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/i18n/locales/en/timeline.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json new file mode 100644 index 00000000..f5f8d728 --- /dev/null +++ b/src/i18n/locales/es/common.json @@ -0,0 +1,7 @@ +{ + "app": { + "name": "Recordly", + "editorTitle": "Editor de Recordly", + "subtitle": "Grabacion de pantalla y edicion" + } +} \ No newline at end of file diff --git a/src/i18n/locales/es/dialogs.json b/src/i18n/locales/es/dialogs.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/i18n/locales/es/dialogs.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/i18n/locales/es/editor.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/i18n/locales/es/launch.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/i18n/locales/es/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/i18n/locales/es/shortcuts.json b/src/i18n/locales/es/shortcuts.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/i18n/locales/es/shortcuts.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/i18n/locales/es/timeline.json b/src/i18n/locales/es/timeline.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/i18n/locales/es/timeline.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index 1c039b74..89aa334b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( - + + + , ) - +