mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
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:
@@ -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.
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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]
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "Recordly",
|
||||
"editorTitle": "Recordly Editor",
|
||||
"subtitle": "Screen recording and editing"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "Recordly",
|
||||
"editorTitle": "Editor de Recordly",
|
||||
"subtitle": "Grabacion de pantalla y edicion"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
+5
-2
@@ -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>,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user