Live video on the AT Protocol

Move i18n setup to @streamplace/components

+708 -766
+1 -1
js/app/components/provider/provider.shared.tsx
··· 21 21 22 22 export default Sentry.wrap(ProviderInner); 23 23 24 + import { i18n } from "@streamplace/components"; 24 25 import * as Application from "expo-application"; 25 26 import Constants from "expo-constants"; 26 27 import * as Updates from "expo-updates"; 27 28 import { Platform } from "react-native"; 28 29 import { SafeAreaProvider } from "react-native-safe-area-context"; 29 - import i18n from "../../src/i18n"; 30 30 Sentry.setExtras({ 31 31 manifest: Updates.manifest, 32 32 linkingUri: Constants.linkingUri,
+1 -1
js/app/components/settings/settings.tsx
··· 6 6 DropdownMenuSeparator, 7 7 DropdownMenuTrigger, 8 8 Input, 9 + manifest, 9 10 ResponsiveDropdownMenuContent, 10 11 Text, 11 12 View, ··· 25 26 import { useTranslation } from "react-i18next"; 26 27 import { ScrollView, Switch } from "react-native"; 27 28 import { useAppDispatch, useAppSelector } from "store/hooks"; 28 - import manifest from "../../src/i18n/manifest.json"; 29 29 import { Updates } from "./updates"; 30 30 import WebhookManager from "./webhook-manager"; 31 31
+20 -16
js/app/scripts/compile-translations.js js/components/scripts/compile-translations.js
··· 14 14 const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf-8")); 15 15 16 16 // Configuration 17 - const LOCALES_DIR = path.join( 18 - __dirname, 19 - "..", 20 - "src", 21 - "i18n", 22 - "locales", 23 - "data", 24 - ); 25 - const LOCALES_BASE_DIR = path.join(__dirname, "..", "src", "i18n", "locales"); 17 + const LOCALES_SOURCE_DIR = path.join(__dirname, "..", "locales"); 18 + const LOCALES_OUTPUT_DIR = path.join(__dirname, "..", "public", "locales"); 26 19 const OUTPUT_FILENAME = "messages.json"; 27 20 28 21 /** ··· 158 151 function compileTranslations() { 159 152 console.log("🌍 Compiling translation files..."); 160 153 161 - if (!fs.existsSync(LOCALES_DIR)) { 162 - console.error(`❌ Locales directory not found: ${LOCALES_DIR}`); 154 + if (!fs.existsSync(LOCALES_SOURCE_DIR)) { 155 + console.error(`❌ Locales directory not found: ${LOCALES_SOURCE_DIR}`); 163 156 process.exit(1); 164 157 } 165 158 159 + // Create output directory if it doesn't exist 160 + if (!fs.existsSync(LOCALES_OUTPUT_DIR)) { 161 + fs.mkdirSync(LOCALES_OUTPUT_DIR, { recursive: true }); 162 + } 163 + 166 164 // Get supported locales from manifest, but only include those with actual data directories 167 165 const manifestLocales = manifest.supportedLocales; 168 166 const locales = manifestLocales.filter((locale) => { 169 - const localeDir = path.join(LOCALES_DIR, locale); 167 + const localeDir = path.join(LOCALES_SOURCE_DIR, locale); 170 168 return fs.existsSync(localeDir) && fs.statSync(localeDir).isDirectory(); 171 169 }); 172 170 173 171 if (locales.length === 0) { 174 - console.error(`❌ No locale directories found in ${LOCALES_DIR}`); 172 + console.error(`❌ No locale directories found in ${LOCALES_SOURCE_DIR}`); 175 173 process.exit(1); 176 174 } 177 175 ··· 179 177 180 178 // Process each locale 181 179 for (const locale of locales) { 182 - const localeDir = path.join(LOCALES_DIR, locale); 183 - const outputPath = path.join(localeDir, OUTPUT_FILENAME); 180 + const localeSourceDir = path.join(LOCALES_SOURCE_DIR, locale); 181 + const localeOutputDir = path.join(LOCALES_OUTPUT_DIR, locale); 182 + const outputPath = path.join(localeOutputDir, OUTPUT_FILENAME); 184 183 185 184 console.log(`📦 Processing locale: ${locale}`); 186 185 186 + // Create locale output directory 187 + if (!fs.existsSync(localeOutputDir)) { 188 + fs.mkdirSync(localeOutputDir, { recursive: true }); 189 + } 190 + 187 191 // Combine all .ftl files for this locale 188 - const combinedContent = combineLocaleFiles(localeDir); 192 + const combinedContent = combineLocaleFiles(localeSourceDir); 189 193 190 194 if (!combinedContent.trim()) { 191 195 console.warn(`⚠️ Skipping ${locale} - no content found`);
-111
js/app/scripts/extract-i18n.js
··· 1 - #!/usr/bin/env node 2 - 3 - /** 4 - * i18n key extraction script using i18next-parser 5 - * Automatically extracts translation keys from your codebase to messages.json files 6 - */ 7 - 8 - const { execSync } = require("child_process"); 9 - const fs = require("fs"); 10 - const path = require("path"); 11 - 12 - // Load manifest to get supported locales 13 - const manifestPath = path.join(__dirname, "../src/i18n/manifest.json"); 14 - const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); 15 - 16 - // Configuration for i18next-parser 17 - const config = { 18 - contextSeparator: "_", 19 - createOldCatalogs: false, 20 - defaultNamespace: "messages", 21 - defaultValue: "", 22 - indentation: 2, 23 - keepRemoved: true, // Keep existing keys 24 - keySeparator: false, // Use flat keys like 'settings-title' instead of nested 25 - namespaceSeparator: false, // No namespace separation 26 - 27 - lexers: { 28 - js: ["JavascriptLexer"], 29 - ts: ["JavascriptLexer"], 30 - jsx: ["JsxLexer"], 31 - tsx: ["JsxLexer"], 32 - // Explicitly disable HTML lexers 33 - html: false, 34 - htm: false, 35 - handlebars: false, 36 - hbs: false, 37 - }, 38 - 39 - locales: manifest.supportedLocales, // Use locales from manifest 40 - 41 - output: "../src/i18n/locales/data/$LOCALE/$NAMESPACE.json", 42 - 43 - input: [ 44 - "../src/**/*.{js,jsx,ts,tsx}", 45 - "../../components/src/**/*.{js,jsx,ts,tsx}", 46 - "!**/node_modules/**", 47 - "!**/dist/**", 48 - "!**/*.test.{js,jsx,ts,tsx}", 49 - "!**/*.spec.{js,jsx,ts,tsx}", 50 - ], 51 - 52 - verbose: true, 53 - sort: true, 54 - failOnWarnings: false, 55 - failOnUpdate: false, // Don't fail when new keys are added 56 - 57 - // Custom function detection 58 - // Look for: t('key'), useTranslation(), Trans component 59 - // These match your i18next setup 60 - }; 61 - 62 - // Create config with absolute paths 63 - const appRoot = path.join(__dirname, ".."); 64 - const configWithAbsolutePaths = { 65 - ...config, 66 - output: path.join(appRoot, "src/i18n/locales/data/$LOCALE/$NAMESPACE.json"), 67 - input: [ 68 - path.join(appRoot, "src/**/*.{js,jsx,ts,tsx}"), 69 - path.join(appRoot, "components/**/*.{js,jsx,ts,tsx}"), 70 - path.join(path.dirname(appRoot), "components/src/**/*.{js,jsx,ts,tsx}"), 71 - "!**/node_modules/**", 72 - "!**/dist/**", 73 - "!**/*.test.{js,jsx,ts,tsx}", 74 - "!**/*.spec.{js,jsx,ts,tsx}", 75 - ], 76 - }; 77 - 78 - // Write config to temporary file 79 - const configPath = path.join(__dirname, "i18next-parser.config.js"); 80 - const configContent = `module.exports = ${JSON.stringify(configWithAbsolutePaths, null, 2)};`; 81 - 82 - try { 83 - // Write config file 84 - fs.writeFileSync(configPath, configContent); 85 - console.log("🔍 Extracting i18n keys from codebase..."); 86 - 87 - // Run i18next-parser from scripts directory so paths are correct 88 - const command = `npx i18next-parser --config ${configPath}`; 89 - execSync(command, { stdio: "inherit", cwd: __dirname }); 90 - 91 - console.log("✅ i18n keys extracted successfully!"); 92 - console.log("\nGenerated files:"); 93 - 94 - // List generated files 95 - config.locales.forEach((locale) => { 96 - const filePath = config.output 97 - .replace("$LOCALE", locale) 98 - .replace("$NAMESPACE", config.defaultNamespace); 99 - if (fs.existsSync(filePath)) { 100 - console.log(` 📄 ${filePath}`); 101 - } 102 - }); 103 - } catch (error) { 104 - console.error("❌ Error extracting i18n keys:", error.message); 105 - process.exit(1); 106 - } finally { 107 - // Clean up config file 108 - if (fs.existsSync(configPath)) { 109 - fs.unlinkSync(configPath); 110 - } 111 - }
-141
js/app/scripts/migrate-keys-to-ftl.js
··· 1 - #!/usr/bin/env node 2 - 3 - /** 4 - * Migrate extracted JSON keys back to .ftl files 5 - * Takes new keys from messages.json and adds them to .ftl files 6 - * If a key already exists in *.ftl, it is skipped 7 - */ 8 - 9 - const fs = require("fs"); 10 - const path = require("path"); 11 - 12 - // Load manifest to get supported locales 13 - const manifestPath = path.join(__dirname, "../src/i18n/manifest.json"); 14 - const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); 15 - 16 - const LOCALES_BASE_DIR = path.join(__dirname, "../src/i18n/locales/data"); 17 - 18 - /** 19 - * Read existing .ftl files for a locale and extract existing keys 20 - */ 21 - function getExistingFtlKeys(localeDir) { 22 - const existingKeys = new Set(); 23 - 24 - if (!fs.existsSync(localeDir)) { 25 - return existingKeys; 26 - } 27 - 28 - const ftlFiles = fs.readdirSync(localeDir).filter((f) => f.endsWith(".ftl")); 29 - 30 - for (const file of ftlFiles) { 31 - const content = fs.readFileSync(path.join(localeDir, file), "utf8"); 32 - const lines = content.split("\n"); 33 - 34 - for (const line of lines) { 35 - const trimmed = line.trim(); 36 - // Match key = value pattern 37 - const keyMatch = trimmed.match(/^([a-zA-Z][a-zA-Z0-9_-]*)\s*=/); 38 - if (keyMatch) { 39 - existingKeys.add(keyMatch[1]); 40 - } 41 - } 42 - } 43 - 44 - return existingKeys; 45 - } 46 - 47 - /** 48 - * Add new keys to the appropriate .ftl file 49 - */ 50 - function addKeysToFtlFile(localeDir, newKeys, locale) { 51 - // Determine which .ftl file to add to (default to common.ftl) 52 - const targetFile = path.join(localeDir, "common.ftl"); 53 - 54 - // Create the file if it doesn't exist 55 - if (!fs.existsSync(targetFile)) { 56 - const header = `# Common translations - ${manifest.languages[locale]?.name || locale}\n\n`; 57 - fs.writeFileSync(targetFile, header); 58 - } 59 - 60 - // Read existing content 61 - let content = fs.readFileSync(targetFile, "utf8"); 62 - 63 - // Add new keys at the end 64 - const newEntries = newKeys.map((key) => `${key} = ${key}`).join("\n"); 65 - 66 - if (!content.endsWith("\n")) { 67 - content += "\n"; 68 - } 69 - 70 - content += "\n# Newly extracted keys\n"; 71 - content += newEntries + "\n"; 72 - 73 - fs.writeFileSync(targetFile, content); 74 - 75 - return targetFile; 76 - } 77 - 78 - /** 79 - * Main migration function 80 - */ 81 - function migrateKeysToFtl() { 82 - console.log("🔄 Migrating extracted keys to .ftl files..."); 83 - 84 - let totalNewKeys = 0; 85 - const processedFiles = []; 86 - 87 - for (const locale of manifest.supportedLocales) { 88 - const localeDir = path.join(LOCALES_BASE_DIR, locale); 89 - const messagesJsonPath = path.join(localeDir, "messages.json"); 90 - 91 - if (!fs.existsSync(messagesJsonPath)) { 92 - console.log(`⚠️ No messages.json found for ${locale}, skipping...`); 93 - continue; 94 - } 95 - 96 - // Read extracted keys from messages.json 97 - const messagesJson = JSON.parse(fs.readFileSync(messagesJsonPath, "utf8")); 98 - const extractedKeys = Object.keys(messagesJson); 99 - 100 - // Get existing keys from .ftl files 101 - const existingKeys = getExistingFtlKeys(localeDir); 102 - 103 - // Find new keys that don't exist in .ftl files 104 - const newKeys = extractedKeys.filter((key) => !existingKeys.has(key)); 105 - 106 - if (newKeys.length === 0) { 107 - console.log(`✅ ${locale}: No new keys to migrate`); 108 - continue; 109 - } 110 - 111 - console.log(`📝 ${locale}: Found ${newKeys.length} new keys to migrate:`); 112 - newKeys.forEach((key) => console.log(` - ${key}`)); 113 - 114 - // Add new keys to .ftl file 115 - const targetFile = addKeysToFtlFile(localeDir, newKeys, locale); 116 - processedFiles.push(path.relative(process.cwd(), targetFile)); 117 - totalNewKeys += newKeys.length; 118 - } 119 - 120 - if (totalNewKeys === 0) { 121 - console.log( 122 - "🎉 No new keys found - all extracted keys already exist in .ftl files!", 123 - ); 124 - } else { 125 - console.log( 126 - `🎉 Migration complete! Added ${totalNewKeys} new keys to .ftl files:`, 127 - ); 128 - processedFiles.forEach((file) => console.log(` 📄 ${file}`)); 129 - 130 - console.log("\n💡 Next steps:"); 131 - console.log(" 1. Review the new keys in your .ftl files"); 132 - console.log( 133 - " 2. Replace the placeholder values with actual translations", 134 - ); 135 - console.log(" 3. Run `pnpm run i18n:compile` to update the JSON files"); 136 - console.log(" 4. Delete the test-i18n.tsx file when done"); 137 - } 138 - } 139 - 140 - // Run the migration 141 - migrateKeysToFtl();
-136
js/app/src/i18n/index.ts
··· 1 - // App-level i18n configuration using Streamplace components library 2 - import i18n from "i18next"; 3 - import LanguageDetector from "i18next-browser-languagedetector"; 4 - import Fluent from "i18next-fluent"; 5 - import Backend from "i18next-http-backend"; 6 - import { initReactI18next } from "react-i18next"; 7 - //import * as RNLocalize from "react-native-localize"; 8 - import manifest from "./manifest.json"; 9 - 10 - // Types 11 - export type SupportedLocale = (typeof manifest.supportedLocales)[number]; 12 - export type LanguageInfo = { 13 - code: string; 14 - name: string; 15 - nativeName: string; 16 - flag: string; 17 - }; 18 - 19 - // Get device locale and map to supported locale 20 - function getDeviceLocale(): SupportedLocale { 21 - //const deviceLocale = RNLocalize.getLocales()?.[0]?.languageTag || "en"; 22 - //const cleanLocale = deviceLocale.replace("_", "-").replace(/@.*/, ""); 23 - 24 - // until we can get a native release out, use browser locale or default to en 25 - const cleanLocale = (navigator?.language || "en") 26 - .replace("_", "-") 27 - .replace(/@.*/, ""); 28 - 29 - // Check exact match first 30 - if (manifest.supportedLocales.includes(cleanLocale as SupportedLocale)) { 31 - return cleanLocale as SupportedLocale; 32 - } 33 - 34 - // Check language part (e.g., "en" from "en-GB") 35 - const lang = cleanLocale.split("-")[0]; 36 - const matchingLocale = manifest.supportedLocales.find((locale) => 37 - locale.startsWith(lang + "-"), 38 - ); 39 - 40 - return (matchingLocale || manifest.fallbackChain[0]) as SupportedLocale; 41 - } 42 - 43 - // Initialize i18next 44 - i18n 45 - .use(initReactI18next) 46 - .use(LanguageDetector) 47 - .use(Backend) 48 - .use(Fluent) 49 - .init({ 50 - // Language settings 51 - lng: getDeviceLocale(), 52 - fallbackLng: { 53 - // Default fallback 54 - default: manifest.fallbackChain, 55 - }, 56 - supportedLngs: [ 57 - ...manifest.supportedLocales, 58 - // Add base language codes to prevent warnings 59 - "zh", 60 - "pt", 61 - "es", 62 - "en", 63 - "fr", 64 - ], 65 - 66 - // Backend configuration 67 - backend: { 68 - loadPath: "/locales/{{lng}}/messages.json", 69 - crossDomain: true, 70 - request: (options: any, url: string, payload: any, callback: any) => { 71 - // Map base language codes to specific variants for file loading 72 - const languageMap: Record<string, string> = { 73 - zh: "zh-TW", 74 - pt: "pt-BR", 75 - es: "es-ES", 76 - en: "en-US", 77 - fr: "fr-FR", 78 - }; 79 - 80 - // Extract language from URL and map it 81 - const urlMatch = url.match(/\/locales\/([^\/]+)\//); 82 - if (urlMatch) { 83 - const lng = urlMatch[1]; 84 - const mappedLng = languageMap[lng] || lng; 85 - url = url.replace(`/locales/${lng}/`, `/locales/${mappedLng}/`); 86 - } 87 - 88 - // Use standard fetch 89 - fetch(url) 90 - .then((response) => { 91 - if (!response.ok) { 92 - throw new Error(`HTTP ${response.status}`); 93 - } 94 - return response.json(); 95 - }) 96 - .then((data) => callback(null, { status: 200, data })) 97 - .catch((error) => callback(error, { status: 500, data: null })); 98 - }, 99 - }, 100 - 101 - // Language detection 102 - detection: { 103 - order: ["localStorage", "navigator"], 104 - lookupLocalStorage: "@streamplace/locale", 105 - caches: ["localStorage"], 106 - }, 107 - 108 - // Fluent plugin settings 109 - i18nFormat: { 110 - fluentBundleOptions: { 111 - useIsolating: false, 112 - functions: { 113 - VOWORCON: ([txt]: [string]) => 114 - "aeiou".indexOf(txt[0]?.toLowerCase() || "") >= 0 ? "vow" : "con", 115 - JOIN: (args: string[], opts: { separator?: string } = {}) => 116 - args.filter(Boolean).join(opts.separator || ""), 117 - }, 118 - }, 119 - }, 120 - 121 - // Development settings 122 - debug: process.env.NODE_ENV === "development", 123 - 124 - // React settings 125 - react: { 126 - useSuspense: false, 127 - }, 128 - 129 - // Disable interpolation since Fluent handles it 130 - interpolation: { 131 - escapeValue: false, 132 - }, 133 - }); 134 - 135 - export default i18n; 136 - export { manifest };
-54
js/app/src/i18n/locales/data/en-US/messages.json
··· 1 - { 2 - "app-version": "Streamplace v{ $version }", 3 - "download-new-update": "Download New Update", 4 - "check-for-updates": "Check for Updates", 5 - "bundled-runtype": "Bundled", 6 - "ota-runtype": "Over-the-Air (OTA)", 7 - "recovery-runtype": "Recovery Mode", 8 - "modal-latest-version": "You are using the latest version.", 9 - "modal-no-update-available": "You are on the latest version of Streamplace, hooray!", 10 - "modal-update-available-title": "Update Available", 11 - "modal-update-available-description": "A new version of Streamplace is ready to download", 12 - "modal-update-failed": "Update check failed. You may need to update the app through the { $store }.", 13 - "modal-update-failed-title": "Update Failed", 14 - "modal-update-failed-description": "Update check failed. You may need to update the app through the { $store }.", 15 - "button-reload-app-on-update": "Apply Update (will reload app)", 16 - "use-custom-node": "Use Custom Node", 17 - "default-url": "Default: { $url }", 18 - "enter-custom-node-url": "Enter custom node URL", 19 - "save-button": "SAVE", 20 - "language-selection": "Language", 21 - "language-selection-description": "Choose your preferred language", 22 - "input-search-languages": "Search languages...", 23 - "debug-recording-title": "Allow { $host } to record your livestream for debugging and improving the service", 24 - "debug-recording-description": "Optional", 25 - "manage-keys": "Manage Keys", 26 - "settings-title": "Settings", 27 - "loading": "Loading...", 28 - "error": "Error", 29 - "cancel": "Cancel", 30 - "confirm": "Confirm", 31 - "welcome-user": "Welcome, { $username }!", 32 - "notification-count": "{ $count ->\n [0] No notifications\n [1] One notification\n *[other] { $count } notifications\n}", 33 - "search-placeholder": "Search...", 34 - "message-input": "Enter your message...", 35 - "success": "Success", 36 - "warning": "Warning", 37 - "info": "Information", 38 - "close": "Close", 39 - "open": "Open", 40 - "delete": "Delete", 41 - "edit": "Edit", 42 - "create": "Create", 43 - "update": "Update", 44 - "refresh": "Refresh", 45 - "save": "Save", 46 - "cancel-button": "Cancel", 47 - "ok": "OK", 48 - "yes": "Yes", 49 - "no": "No", 50 - "continue": "Continue", 51 - "back": "Back", 52 - "next": "Next", 53 - "finish": "Finish" 54 - }
js/app/src/i18n/locales/data/en-US/settings.ftl js/components/locales/en-US/settings.ftl
-54
js/app/src/i18n/locales/data/es-ES/messages.json
··· 1 - { 2 - "app-version": "Streamplace v{ $version }", 3 - "download-new-update": "Descargar Nueva Actualización", 4 - "check-for-updates": "Buscar Actualizaciones", 5 - "bundled-runtype": "Empaquetado", 6 - "ota-runtype": "Over-the-Air (OTA)", 7 - "recovery-runtype": "Modo de Recuperación", 8 - "modal-latest-version": "Estás usando la última versión.", 9 - "modal-no-update-available": "¡Tienes la versión más reciente de Streamplace, genial!", 10 - "modal-update-available-title": "Actualización Disponible", 11 - "modal-update-available-description": "Una nueva versión de Streamplace está lista para descargar", 12 - "modal-update-failed": "La búsqueda de actualizaciones falló. Es posible que necesites actualizar", 13 - "modal-update-failed-title": "Actualización Falló", 14 - "modal-update-failed-description": "La búsqueda de actualizaciones falló. Es posible que necesites actualizar", 15 - "button-reload-app-on-update": "Aplicar Actualización (recargará la aplicación)", 16 - "use-custom-node": "Usar Nodo Personalizado", 17 - "default-url": "Predeterminado: { $url }", 18 - "enter-custom-node-url": "Introduce la URL del nodo personalizado", 19 - "save-button": "GUARDAR", 20 - "language-selection": "Idioma", 21 - "language-selection-description": "Elige tu idioma preferido", 22 - "debug-recording-title": "Permitir que { $host } grabe tu retransmisión en directo para depuración y mejora del servicio", 23 - "debug-recording-description": "Opcional", 24 - "input-search-languages": "Buscar idiomas...", 25 - "manage-keys": "Gestionar Claves", 26 - "settings-title": "Configuración", 27 - "loading": "Cargando...", 28 - "error": "Error", 29 - "cancel": "Cancelar", 30 - "confirm": "Confirmar", 31 - "welcome-user": "¡Bienvenido, { $username }!", 32 - "notification-count": "{ $count ->\n [0] Sin notificaciones\n [1] Una notificación\n *[other] { $count } notificaciones\n}", 33 - "search-placeholder": "Buscar...", 34 - "message-input": "Introduce tu mensaje...", 35 - "success": "Éxito", 36 - "warning": "Aviso", 37 - "info": "Información", 38 - "close": "Cerrar", 39 - "open": "Abrir", 40 - "delete": "Eliminar", 41 - "edit": "Editar", 42 - "create": "Crear", 43 - "update": "Actualizar", 44 - "refresh": "Actualizar", 45 - "save": "Guardar", 46 - "cancel-button": "Cancelar", 47 - "ok": "Aceptar", 48 - "yes": "Sí", 49 - "no": "No", 50 - "continue": "Continuar", 51 - "back": "Volver", 52 - "next": "Siguiente", 53 - "finish": "Finalizar" 54 - }
js/app/src/i18n/locales/data/es-ES/settings.ftl js/components/locales/es-ES/settings.ftl
-54
js/app/src/i18n/locales/data/fr-FR/messages.json
··· 1 - { 2 - "app-version": "Streamplace v{ $version }", 3 - "download-new-update": "Télécharger la nouvelle mise à jour", 4 - "check-for-updates": "Vérifier les mises à jour", 5 - "bundled-runtype": "Inclus", 6 - "ota-runtype": "Over-the-Air (OTA)", 7 - "recovery-runtype": "Mode de récupération", 8 - "modal-latest-version": "Vous utilisez la dernière version.", 9 - "modal-no-update-available": "Vous avez la dernière version de Streamplace, génial !", 10 - "modal-update-available-title": "Mise à jour disponible", 11 - "modal-update-available-description": "Une nouvelle version de Streamplace est prête à télécharger", 12 - "modal-update-failed": "La vérification des mises à jour a échoué. Vous devrez peut-être mettre à jour l'application via { $store }.", 13 - "modal-update-failed-title": "Échec de la mise à jour", 14 - "modal-update-failed-description": "La vérification des mises à jour a échoué. Vous devrez peut-être mettre à jour l'application via { $store }.", 15 - "button-reload-app-on-update": "Appliquer la mise à jour (l'application va se recharger)", 16 - "use-custom-node": "Utiliser un nœud personnalisé", 17 - "default-url": "Par défaut : { $url }", 18 - "enter-custom-node-url": "Saisir l'URL du nœud personnalisé", 19 - "save-button": "ENREGISTRER", 20 - "language-selection": "Langue", 21 - "language-selection-description": "Choisissez votre langue préférée", 22 - "debug-recording-title": "Autoriser { $host } à enregistrer votre diffusion en direct pour le débogage et l'amélioration du service", 23 - "debug-recording-description": "Optionnel", 24 - "input-search-languages": "Rechercher des langues...", 25 - "manage-keys": "Gérer les clés", 26 - "settings-title": "Paramètres", 27 - "loading": "Chargement...", 28 - "error": "Erreur", 29 - "cancel": "Annuler", 30 - "confirm": "Confirmer", 31 - "welcome-user": "Bienvenue, { $username } !", 32 - "notification-count": "{ $count ->\n [0] Aucune notification\n [1] Une notification\n *[other] { $count } notifications\n}", 33 - "search-placeholder": "Rechercher...", 34 - "message-input": "Saisissez votre message...", 35 - "success": "Succès", 36 - "warning": "Attention", 37 - "info": "Information", 38 - "close": "Fermer", 39 - "open": "Ouvrir", 40 - "delete": "Supprimer", 41 - "edit": "Modifier", 42 - "create": "Créer", 43 - "update": "Mettre à jour", 44 - "refresh": "Actualiser", 45 - "save": "Enregistrer", 46 - "cancel-button": "Annuler", 47 - "ok": "OK", 48 - "yes": "Oui", 49 - "no": "Non", 50 - "continue": "Continuer", 51 - "back": "Retour", 52 - "next": "Suivant", 53 - "finish": "Terminer" 54 - }
js/app/src/i18n/locales/data/fr-FR/settings.ftl js/components/locales/fr-FR/settings.ftl
-54
js/app/src/i18n/locales/data/pt-BR/messages.json
··· 1 - { 2 - "app-version": "Streamplace v{ $version }", 3 - "download-new-update": "Baixar Nova Atualização", 4 - "check-for-updates": "Verificar Atualizações", 5 - "bundled-runtype": "Empacotado", 6 - "ota-runtype": "Over-the-Air (OTA)", 7 - "recovery-runtype": "Modo de Recuperação", 8 - "modal-latest-version": "Você está usando a versão mais recente.", 9 - "modal-no-update-available": "Você está na versão mais recente do Streamplace, eba!", 10 - "modal-update-available-title": "Atualização Disponível", 11 - "modal-update-available-description": "Uma nova versão do Streamplace está pronta para download", 12 - "modal-update-failed": "A verificação de atualizações falhou. Você pode precisar atualizar o aplicativo através do { $store }.", 13 - "modal-update-failed-title": "Atualização Falhou", 14 - "modal-update-failed-description": "A verificação de atualizações falhou. Você pode precisar atualizar o aplicativo através do { $store }.", 15 - "button-reload-app-on-update": "Aplicar Atualização (o aplicativo será recarregado)", 16 - "use-custom-node": "Usar Nó Personalizado", 17 - "default-url": "Padrão: { $url }", 18 - "enter-custom-node-url": "Digite a URL do nó personalizado", 19 - "save-button": "SALVAR", 20 - "language-selection": "Idioma", 21 - "language-selection-description": "Escolha seu idioma preferido", 22 - "debug-recording-title": "Permitir que { $host } grave sua transmissão ao vivo para depuração e melhoria do serviço", 23 - "debug-recording-description": "Opcional", 24 - "input-search-languages": "Pesquisar idiomas...", 25 - "manage-keys": "Gerenciar Chaves", 26 - "settings-title": "Configurações", 27 - "loading": "Carregando...", 28 - "error": "Erro", 29 - "cancel": "Cancelar", 30 - "confirm": "Confirmar", 31 - "welcome-user": "Bem-vindo, { $username }!", 32 - "notification-count": "{ $count ->\n [0] Nenhuma notificação\n [1] Uma notificação\n *[other] { $count } notificações\n}", 33 - "search-placeholder": "Pesquisar...", 34 - "message-input": "Digite sua mensagem...", 35 - "success": "Sucesso", 36 - "warning": "Aviso", 37 - "info": "Informação", 38 - "close": "Fechar", 39 - "open": "Abrir", 40 - "delete": "Excluir", 41 - "edit": "Editar", 42 - "create": "Criar", 43 - "update": "Atualizar", 44 - "refresh": "Atualizar", 45 - "save": "Salvar", 46 - "cancel-button": "Cancelar", 47 - "ok": "OK", 48 - "yes": "Sim", 49 - "no": "Não", 50 - "continue": "Continuar", 51 - "back": "Voltar", 52 - "next": "Próximo", 53 - "finish": "Finalizar" 54 - }
js/app/src/i18n/locales/data/pt-BR/settings.ftl js/components/locales/pt-BR/settings.ftl
-54
js/app/src/i18n/locales/data/zh-TW/messages.json
··· 1 - { 2 - "app-version": "直播地 v{ $version }", 3 - "download-new-update": "下載新更新", 4 - "check-for-updates": "檢查更新", 5 - "bundled-runtype": "捆綁版", 6 - "ota-runtype": "空中下載 (OTA)", 7 - "recovery-runtype": "復原模式", 8 - "modal-latest-version": "您正在使用最新版本。", 9 - "modal-no-update-available": "您已經在使用最新版本的直播地,太棒了!", 10 - "modal-update-available-title": "有可用更新", 11 - "modal-update-available-description": "新版本的直播地已準備好下載", 12 - "modal-update-failed": "更新檢查失敗。您可能需要透過 { $store } 更新應用程式。", 13 - "modal-update-failed-title": "更新失敗", 14 - "modal-update-failed-description": "更新檢查失敗。您可能需要透過 { $store } 更新應用程式。", 15 - "button-reload-app-on-update": "套用更新 (將重新載入應用程式)", 16 - "use-custom-node": "使用自訂節點", 17 - "default-url": "預設:{ $url }", 18 - "enter-custom-node-url": "輸入自訂節點網址", 19 - "save-button": "儲存", 20 - "language-selection": "語言", 21 - "language-selection-description": "選擇您偏好的語言", 22 - "debug-recording-title": "允許 { $host } 錄製您的直播串流以進行除錯和服務改善", 23 - "debug-recording-description": "可選項目", 24 - "input-search-languages": "搜尋語言...", 25 - "manage-keys": "管理金鑰", 26 - "settings-title": "設定", 27 - "loading": "載入中...", 28 - "error": "錯誤", 29 - "cancel": "取消", 30 - "confirm": "確認", 31 - "welcome-user": "歡迎,{ $username }!", 32 - "notification-count": "{ $count ->\n [0] 無通知\n [1] 一則通知\n *[other] { $count } 則通知\n}", 33 - "search-placeholder": "搜尋...", 34 - "message-input": "請輸入您的訊息...", 35 - "success": "成功", 36 - "warning": "警告", 37 - "info": "資訊", 38 - "close": "關閉", 39 - "open": "開啟", 40 - "delete": "刪除", 41 - "edit": "編輯", 42 - "create": "建立", 43 - "update": "更新", 44 - "refresh": "重新整理", 45 - "save": "儲存", 46 - "cancel-button": "取消", 47 - "ok": "確定", 48 - "yes": "是", 49 - "no": "否", 50 - "continue": "繼續", 51 - "back": "返回", 52 - "next": "下一步", 53 - "finish": "完成" 54 - }
js/app/src/i18n/locales/data/zh-TW/settings.ftl js/components/locales/zh-TW/settings.ftl
-36
js/app/src/i18n/manifest.json
··· 1 - { 2 - "supportedLocales": ["en-US", "pt-BR", "es-ES", "zh-TW", "fr-FR"], 3 - "fallbackChain": ["en-US"], 4 - "languages": { 5 - "en-US": { 6 - "code": "en-US", 7 - "name": "English", 8 - "nativeName": "English", 9 - "flag": "🇺🇸" 10 - }, 11 - "pt-BR": { 12 - "code": "pt-BR", 13 - "name": "Portuguese", 14 - "nativeName": "Português", 15 - "flag": "🇧🇷" 16 - }, 17 - "es-ES": { 18 - "code": "es-ES", 19 - "name": "Spanish", 20 - "nativeName": "Español", 21 - "flag": "🇪🇸" 22 - }, 23 - "zh-TW": { 24 - "code": "zh-TW", 25 - "name": "Chinese Traditional", 26 - "nativeName": "繁體中文", 27 - "flag": "🇹🇼" 28 - }, 29 - "fr-FR": { 30 - "code": "fr-FR", 31 - "name": "French", 32 - "nativeName": "Français", 33 - "flag": "🇫🇷" 34 - } 35 - } 36 - }
+9
js/components/.gitignore
··· 1 + # Ignore compiled translation files in source directory 2 + locales/**/messages.json 3 + 4 + # Ignore extracted i18n keys (they're just for reference) 5 + locales/**/*.json 6 + 7 + # TypeScript build output 8 + dist/ 9 + *.tsbuildinfo
+9 -2
js/components/package.json
··· 58 58 "zustand": "^5.0.5" 59 59 }, 60 60 "peerDependencies": { 61 - "react": "*" 61 + "react": "*", 62 + "expo-localization": "*" 63 + }, 64 + "peerDependenciesMeta": { 65 + "expo-localization": { 66 + "optional": true 67 + } 62 68 }, 63 69 "scripts": { 64 70 "build": "tsc", 65 71 "start": "tsc --watch --preserveWatchOutput", 66 72 "prepare": "tsc", 67 73 "i18n:compile": "node scripts/compile-translations.js", 68 - "i18n:watch": "nodemon scripts/compile-translations.js --watch src/i18n/locales/data/**/*.ftl" 74 + "i18n:watch": "nodemon scripts/compile-translations.js --watch locales/**/*.ftl", 75 + "i18n:extract": "node scripts/extract-i18n.js" 69 76 } 70 77 }
+14
js/components/public/locales/en-US/messages.json
··· 1 1 { 2 2 "app-version": "Streamplace v{ $version }", 3 + "download-new-update": "Download New Update", 4 + "check-for-updates": "Check for Updates", 5 + "bundled-runtype": "Bundled", 6 + "ota-runtype": "Over-the-Air (OTA)", 7 + "recovery-runtype": "Recovery Mode", 8 + "modal-latest-version": "You are using the latest version.", 9 + "modal-no-update-available": "You are on the latest version of Streamplace, hooray!", 10 + "modal-update-available-title": "Update Available", 11 + "modal-update-available-description": "A new version of Streamplace is ready to download", 12 + "modal-update-failed": "Update check failed. You may need to update the app through the { $store }.", 13 + "modal-update-failed-title": "Update Failed", 14 + "modal-update-failed-description": "Update check failed. You may need to update the app through the { $store }.", 15 + "button-reload-app-on-update": "Apply Update (will reload app)", 3 16 "use-custom-node": "Use Custom Node", 4 17 "default-url": "Default: { $url }", 5 18 "enter-custom-node-url": "Enter custom node URL", 6 19 "save-button": "SAVE", 7 20 "language-selection": "Language", 8 21 "language-selection-description": "Choose your preferred language", 22 + "input-search-languages": "Search languages...", 9 23 "debug-recording-title": "Allow { $host } to record your livestream for debugging and improving the service", 10 24 "debug-recording-description": "Optional", 11 25 "manage-keys": "Manage Keys",
+14
js/components/public/locales/es-ES/messages.json
··· 1 1 { 2 2 "app-version": "Streamplace v{ $version }", 3 + "download-new-update": "Descargar Nueva Actualización", 4 + "check-for-updates": "Buscar Actualizaciones", 5 + "bundled-runtype": "Empaquetado", 6 + "ota-runtype": "Over-the-Air (OTA)", 7 + "recovery-runtype": "Modo de Recuperación", 8 + "modal-latest-version": "Estás usando la última versión.", 9 + "modal-no-update-available": "¡Tienes la versión más reciente de Streamplace, genial!", 10 + "modal-update-available-title": "Actualización Disponible", 11 + "modal-update-available-description": "Una nueva versión de Streamplace está lista para descargar", 12 + "modal-update-failed": "La búsqueda de actualizaciones falló. Es posible que necesites actualizar", 13 + "modal-update-failed-title": "Actualización Falló", 14 + "modal-update-failed-description": "La búsqueda de actualizaciones falló. Es posible que necesites actualizar", 15 + "button-reload-app-on-update": "Aplicar Actualización (recargará la aplicación)", 3 16 "use-custom-node": "Usar Nodo Personalizado", 4 17 "default-url": "Predeterminado: { $url }", 5 18 "enter-custom-node-url": "Introduce la URL del nodo personalizado", ··· 8 21 "language-selection-description": "Elige tu idioma preferido", 9 22 "debug-recording-title": "Permitir que { $host } grabe tu retransmisión en directo para depuración y mejora del servicio", 10 23 "debug-recording-description": "Opcional", 24 + "input-search-languages": "Buscar idiomas...", 11 25 "manage-keys": "Gestionar Claves", 12 26 "settings-title": "Configuración", 13 27 "loading": "Cargando...",
+14
js/components/public/locales/fr-FR/messages.json
··· 1 1 { 2 2 "app-version": "Streamplace v{ $version }", 3 + "download-new-update": "Télécharger la nouvelle mise à jour", 4 + "check-for-updates": "Vérifier les mises à jour", 5 + "bundled-runtype": "Inclus", 6 + "ota-runtype": "Over-the-Air (OTA)", 7 + "recovery-runtype": "Mode de récupération", 8 + "modal-latest-version": "Vous utilisez la dernière version.", 9 + "modal-no-update-available": "Vous avez la dernière version de Streamplace, génial !", 10 + "modal-update-available-title": "Mise à jour disponible", 11 + "modal-update-available-description": "Une nouvelle version de Streamplace est prête à télécharger", 12 + "modal-update-failed": "La vérification des mises à jour a échoué. Vous devrez peut-être mettre à jour l'application via { $store }.", 13 + "modal-update-failed-title": "Échec de la mise à jour", 14 + "modal-update-failed-description": "La vérification des mises à jour a échoué. Vous devrez peut-être mettre à jour l'application via { $store }.", 15 + "button-reload-app-on-update": "Appliquer la mise à jour (l'application va se recharger)", 3 16 "use-custom-node": "Utiliser un nœud personnalisé", 4 17 "default-url": "Par défaut : { $url }", 5 18 "enter-custom-node-url": "Saisir l'URL du nœud personnalisé", ··· 8 21 "language-selection-description": "Choisissez votre langue préférée", 9 22 "debug-recording-title": "Autoriser { $host } à enregistrer votre diffusion en direct pour le débogage et l'amélioration du service", 10 23 "debug-recording-description": "Optionnel", 24 + "input-search-languages": "Rechercher des langues...", 11 25 "manage-keys": "Gérer les clés", 12 26 "settings-title": "Paramètres", 13 27 "loading": "Chargement...",
+14
js/components/public/locales/pt-BR/messages.json
··· 1 1 { 2 2 "app-version": "Streamplace v{ $version }", 3 + "download-new-update": "Baixar Nova Atualização", 4 + "check-for-updates": "Verificar Atualizações", 5 + "bundled-runtype": "Empacotado", 6 + "ota-runtype": "Over-the-Air (OTA)", 7 + "recovery-runtype": "Modo de Recuperação", 8 + "modal-latest-version": "Você está usando a versão mais recente.", 9 + "modal-no-update-available": "Você está na versão mais recente do Streamplace, eba!", 10 + "modal-update-available-title": "Atualização Disponível", 11 + "modal-update-available-description": "Uma nova versão do Streamplace está pronta para download", 12 + "modal-update-failed": "A verificação de atualizações falhou. Você pode precisar atualizar o aplicativo através do { $store }.", 13 + "modal-update-failed-title": "Atualização Falhou", 14 + "modal-update-failed-description": "A verificação de atualizações falhou. Você pode precisar atualizar o aplicativo através do { $store }.", 15 + "button-reload-app-on-update": "Aplicar Atualização (o aplicativo será recarregado)", 3 16 "use-custom-node": "Usar Nó Personalizado", 4 17 "default-url": "Padrão: { $url }", 5 18 "enter-custom-node-url": "Digite a URL do nó personalizado", ··· 8 21 "language-selection-description": "Escolha seu idioma preferido", 9 22 "debug-recording-title": "Permitir que { $host } grave sua transmissão ao vivo para depuração e melhoria do serviço", 10 23 "debug-recording-description": "Opcional", 24 + "input-search-languages": "Pesquisar idiomas...", 11 25 "manage-keys": "Gerenciar Chaves", 12 26 "settings-title": "Configurações", 13 27 "loading": "Carregando...",
+15 -1
js/components/public/locales/zh-TW/messages.json
··· 1 1 { 2 - "app-version": "Streamplace v{ $version }", 2 + "app-version": "直播地 v{ $version }", 3 + "download-new-update": "下載新更新", 4 + "check-for-updates": "檢查更新", 5 + "bundled-runtype": "捆綁版", 6 + "ota-runtype": "空中下載 (OTA)", 7 + "recovery-runtype": "復原模式", 8 + "modal-latest-version": "您正在使用最新版本。", 9 + "modal-no-update-available": "您已經在使用最新版本的直播地,太棒了!", 10 + "modal-update-available-title": "有可用更新", 11 + "modal-update-available-description": "新版本的直播地已準備好下載", 12 + "modal-update-failed": "更新檢查失敗。您可能需要透過 { $store } 更新應用程式。", 13 + "modal-update-failed-title": "更新失敗", 14 + "modal-update-failed-description": "更新檢查失敗。您可能需要透過 { $store } 更新應用程式。", 15 + "button-reload-app-on-update": "套用更新 (將重新載入應用程式)", 3 16 "use-custom-node": "使用自訂節點", 4 17 "default-url": "預設:{ $url }", 5 18 "enter-custom-node-url": "輸入自訂節點網址", ··· 8 21 "language-selection-description": "選擇您偏好的語言", 9 22 "debug-recording-title": "允許 { $host } 錄製您的直播串流以進行除錯和服務改善", 10 23 "debug-recording-description": "可選項目", 24 + "input-search-languages": "搜尋語言...", 11 25 "manage-keys": "管理金鑰", 12 26 "settings-title": "設定", 13 27 "loading": "載入中...",
+221
js/components/scripts/extract-i18n.js
··· 1 + #!/usr/bin/env node 2 + 3 + /** 4 + * i18n key extraction script 5 + * 1. Scans the codebase for i18n keys (t('key'), Trans components, etc) 6 + * 2. ExtractsJSONmessages.json 7 + * 3. Migrates new keys to json files for translation 8 + */ 9 + 10 + const { execSync } = require("child_process"); 11 + const fs = require("fs"); 12 + const path = require("path"); 13 + 14 + // Paths 15 + const COMPONENTS_ROOT = path.join(__dirname, ".."); 16 + const APP_ROOT = path.join(__dirname, "..", "..", "app"); 17 + const MANIFEST_PATH = path.join(COMPONENTS_ROOT, "locales/manifest.json"); 18 + const LOCALES_DIR = path.join(COMPONENTS_ROOT, "locales"); 19 + 20 + // Load manifest 21 + const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf8")); 22 + 23 + // Configuration for i18next-parser 24 + const parserConfig = { 25 + contextSeparator: "_", 26 + createOldCatalogs: false, 27 + defaultNamespace: "messages", 28 + defaultValue: "", 29 + indentation: 2, 30 + keepRemoved: true, 31 + keySeparator: false, 32 + namespaceSeparator: false, 33 + 34 + lexers: { 35 + js: ["JavascriptLexer"], 36 + ts: ["JavascriptLexer"], 37 + jsx: ["JsxLexer"], 38 + tsx: ["JsxLexer"], 39 + html: false, 40 + htm: false, 41 + handlebars: false, 42 + hbs: false, 43 + }, 44 + 45 + locales: manifest.supportedLocales, 46 + output: path.join(LOCALES_DIR, "$LOCALE/$NAMESPACE.json"), 47 + input: [ 48 + path.join(COMPONENTS_ROOT, "src/**/*.{js,jsx,ts,tsx}"), 49 + path.join(APP_ROOT, "src/**/*.{js,jsx,ts,tsx}"), 50 + path.join(APP_ROOT, "components/**/*.{js,jsx,ts,tsx}"), 51 + "!**/node_modules/**", 52 + "!**/dist/**", 53 + "!**/*.test.{js,jsx,ts,tsx}", 54 + "!**/*.spec.{js,jsx,ts,tsx}", 55 + ], 56 + 57 + verbose: true, 58 + sort: true, 59 + failOnWarnings: false, 60 + failOnUpdate: false, 61 + }; 62 + 63 + /** 64 + * Extract keys from codebase using i18next-parser 65 + */ 66 + function extractKeys() { 67 + const configPath = path.join(__dirname, ".i18next-parser.config.js"); 68 + const configContent = `module.exports = ${JSON.stringify(parserConfig, null, 2)};`; 69 + 70 + try { 71 + fs.writeFileSync(configPath, configContent); 72 + console.log("🔍 Extracting i18n keys from codebase..."); 73 + 74 + execSync(`npx i18next-parser --config ${configPath}`, { 75 + stdio: "inherit", 76 + cwd: __dirname, 77 + }); 78 + 79 + console.log("✅ Keys extracted successfully!"); 80 + return true; 81 + } catch (error) { 82 + console.error("❌ Error extracting i18n keys:", error.message); 83 + return false; 84 + } finally { 85 + if (fs.existsSync(configPath)) { 86 + fs.unlinkSync(configPath); 87 + } 88 + } 89 + } 90 + 91 + /** 92 + * Read existing keys from .ftl files in a locale directory 93 + */ 94 + function getExistingFtlKeys(localeDir) { 95 + const existingKeys = new Set(); 96 + 97 + if (!fs.existsSync(localeDir)) { 98 + return existingKeys; 99 + } 100 + 101 + const ftlFiles = fs 102 + .readdirSync(localeDir) 103 + .filter((file) => file.endsWith(".ftl")); 104 + 105 + for (const file of ftlFiles) { 106 + const content = fs.readFileSync(path.join(localeDir, file), "utf8"); 107 + const lines = content.split("\n"); 108 + 109 + for (const line of lines) { 110 + const trimmed = line.trim(); 111 + const keyMatch = trimmed.match(/^([a-zA-Z][a-zA-Z0-9_-]*)\s*=/); 112 + if (keyMatch) { 113 + existingKeys.add(keyMatch[1]); 114 + } 115 + } 116 + } 117 + 118 + return existingKeys; 119 + } 120 + 121 + /** 122 + * Add new keys to a .ftl file 123 + */ 124 + function addKeysToFtlFile(localeDir, newKeys, locale) { 125 + const targetFile = path.join(localeDir, "common.ftl"); 126 + 127 + // Create file with header if it doesn't exist 128 + if (!fs.existsSync(localeDir)) { 129 + fs.mkdirSync(localeDir, { recursive: true }); 130 + } 131 + 132 + if (!fs.existsSync(targetFile)) { 133 + const languageName = manifest.languages[locale]?.name || locale; 134 + const header = `# Common translations - ${languageName}\n\n`; 135 + fs.writeFileSync(targetFile, header); 136 + } 137 + 138 + // Append new keys 139 + let content = fs.readFileSync(targetFile, "utf8"); 140 + 141 + if (!content.endsWith("\n")) { 142 + content += "\n"; 143 + } 144 + 145 + content += "\n# Newly extracted keys\n"; 146 + content += newKeys.map((key) => `${key} = ${key}`).join("\n") + "\n"; 147 + 148 + fs.writeFileSync(targetFile, content); 149 + 150 + return targetFile; 151 + } 152 + 153 + /** 154 + * Migrate extracted JSON keys to .ftl files 155 + */ 156 + function migrateKeysToFtl() { 157 + console.log("\n🔄 Migrating extracted keys to .ftl files..."); 158 + 159 + let totalNewKeys = 0; 160 + const processedFiles = []; 161 + 162 + for (const locale of manifest.supportedLocales) { 163 + const localeDir = path.join(LOCALES_DIR, locale); 164 + const messagesJsonPath = path.join(localeDir, "messages.json"); 165 + 166 + if (!fs.existsSync(messagesJsonPath)) { 167 + console.log(`⚠️ No messages.json found for ${locale}, skipping...`); 168 + continue; 169 + } 170 + 171 + // Read extracted keys 172 + const messagesJson = JSON.parse(fs.readFileSync(messagesJsonPath, "utf8")); 173 + const extractedKeys = Object.keys(messagesJson); 174 + 175 + // Get existing keys from .ftl files 176 + const existingKeys = getExistingFtlKeys(localeDir); 177 + 178 + // Find new keys 179 + const newKeys = extractedKeys.filter((key) => !existingKeys.has(key)); 180 + 181 + if (newKeys.length === 0) { 182 + console.log(`✅ ${locale}: Found 0 keys to migrate.`); 183 + continue; 184 + } 185 + 186 + console.log(`📝 ${locale}: Found ${newKeys.length} new keys to migrate:`); 187 + newKeys.forEach((key) => console.log(` - ${key}`)); 188 + 189 + // Add to .ftl file 190 + const targetFile = addKeysToFtlFile(localeDir, newKeys, locale); 191 + processedFiles.push(path.relative(process.cwd(), targetFile)); 192 + totalNewKeys += newKeys.length; 193 + } 194 + 195 + // Summary 196 + if (totalNewKeys === 0) { 197 + console.log("\n🎉 No new keys found."); 198 + } else { 199 + console.log( 200 + `\n🎉 Migration complete! Added ${totalNewKeys} new keys to .ftl files:`, 201 + ); 202 + processedFiles.forEach((file) => console.log(` 📄 ${file}`)); 203 + 204 + console.log("\n💡 Next steps:"); 205 + console.log(" 1. Review the new keys in your .ftl files"); 206 + console.log(" 2. Replace placeholder values with actual translations"); 207 + console.log(" 3. Run `pnpm i18n:compile` to update compiled JSON files"); 208 + } 209 + } 210 + 211 + function main() { 212 + const success = extractKeys(); 213 + 214 + if (success) { 215 + migrateKeysToFtl(); 216 + } else { 217 + process.exit(1); 218 + } 219 + } 220 + 221 + main();
+246
js/components/src/i18n/i18next-config.ts
··· 1 + // TypeScript i18next configuration with Fluent and manifest integration 2 + // modified from https://github.com/inaturalist/iNaturalistReactNative/blob/main/src/i18n/initI18next.js 3 + 4 + import i18next from "i18next"; 5 + import Fluent from "i18next-fluent"; 6 + import resourcesToBackend from "i18next-resources-to-backend"; 7 + import "intl-pluralrules"; 8 + import { initReactI18next } from "react-i18next"; 9 + 10 + // Import our manifest and loader 11 + import { manifest } from "./index"; 12 + 13 + // Try to import expo-localization, but make it optional 14 + let Localization: typeof import("expo-localization") | null = null; 15 + try { 16 + Localization = require("expo-localization"); 17 + } catch { 18 + // expo-localization not available, will use browser/fallback detection 19 + } 20 + 21 + // Mock storage for now - replace with actual zustand storage 22 + const storage = { 23 + getItem: (key: string): string | null => { 24 + if (typeof window !== "undefined") { 25 + return localStorage.getItem(key); 26 + } 27 + return null; 28 + }, 29 + setItem: (key: string, value: string): void => { 30 + if (typeof window !== "undefined") { 31 + localStorage.setItem(key, value); 32 + } 33 + }, 34 + }; 35 + 36 + function cleanLocaleName(locale: string): string { 37 + return locale.replace("_", "-").replace(/@.*/, ""); 38 + } 39 + 40 + export function getLocaleFromSystemLocale(): string { 41 + let systemLocale = "en"; 42 + 43 + // Try to get locale from expo-localization if available 44 + if (Localization) { 45 + systemLocale = Localization.getLocales()[0]?.languageTag || "en"; 46 + } else if (typeof navigator !== "undefined" && navigator.language) { 47 + // Fallback to browser navigator.language 48 + systemLocale = navigator.language; 49 + } 50 + 51 + const candidateLocale = cleanLocaleName(systemLocale); 52 + 53 + // Check if the full locale is supported (e.g., "en-US") 54 + if (manifest.supportedLocales.includes(candidateLocale)) { 55 + return candidateLocale; 56 + } 57 + 58 + // Check if the language part is supported (e.g., "en" from "en-GB") 59 + const lang = candidateLocale.split("-")[0]; 60 + const matchingLocale = manifest.supportedLocales.find((locale) => 61 + locale.startsWith(lang + "-"), 62 + ); 63 + 64 + if (matchingLocale) { 65 + return matchingLocale; 66 + } 67 + 68 + // Fall back to default locale from manifest 69 + return manifest.fallbackChain[0]; 70 + } 71 + 72 + export function getCurrentLocale(): string { 73 + const stored = storage.getItem("@streamplace/locale"); 74 + if (stored && manifest.supportedLocales.includes(stored)) { 75 + return stored; 76 + } 77 + return getLocaleFromSystemLocale(); 78 + } 79 + 80 + // Enhanced fallback logic using manifest 81 + function getFallbackChain(code: string): string[] { 82 + const fallbacks: string[] = []; 83 + 84 + // Regional fallbacks 85 + if (code.match(/^es-/)) { 86 + fallbacks.push("es-ES"); // Spanish fallback 87 + } else if (code.match(/^fr-/)) { 88 + fallbacks.push("fr-FR"); // French fallback 89 + } else if (code.match(/^pt-/)) { 90 + fallbacks.push("pt-BR"); // Portuguese fallback 91 + } else if (code.match(/^zh-/)) { 92 + fallbacks.push("zh-TW"); // Chinese fallback 93 + } 94 + 95 + // Add manifest fallback chain 96 + return [...fallbacks, ...manifest.fallbackChain]; 97 + } 98 + 99 + const LOCALE = getCurrentLocale(); 100 + 101 + export const I18NEXT_CONFIG = { 102 + lng: LOCALE, 103 + interpolation: { 104 + escapeValue: false, // React already safes from XSS 105 + }, 106 + react: { 107 + useSuspense: false, // Prevent Android crashes 108 + }, 109 + i18nFormat: { 110 + fluentBundleOptions: { 111 + useIsolating: false, 112 + functions: { 113 + VOWORCON: ([txt]: [string]) => 114 + "aeiou".indexOf(txt[0].toLowerCase()) >= 0 ? "vow" : "con", 115 + JOIN: (args: string[], opts: { separator?: string } = {}) => 116 + args 117 + .filter(Boolean) 118 + .filter((s) => typeof s === "string") 119 + .join(opts.separator || ""), 120 + }, 121 + }, 122 + }, 123 + fallbackLng: getFallbackChain, 124 + supportedLngs: manifest.supportedLocales, 125 + debug: process.env.NODE_ENV === "development", 126 + }; 127 + 128 + // Translation loading function that loads compiled JSON files 129 + async function loadTranslationData(locale: string): Promise<any> { 130 + try { 131 + if (!manifest.supportedLocales.includes(locale)) { 132 + throw new Error(`Unsupported locale: ${locale}`); 133 + } 134 + 135 + let translations: any = {}; 136 + 137 + try { 138 + // For web environments, load from public directory 139 + if (typeof window !== "undefined") { 140 + const response = await fetch(`/locales/${locale}/messages.json`); 141 + if (!response.ok) { 142 + throw new Error(`HTTP ${response.status}`); 143 + } 144 + translations = await response.json(); 145 + } else { 146 + // For React Native, use static requires for bundler compatibility 147 + switch (locale) { 148 + case "en-US": 149 + translations = require("../../public/locales/en-US/messages.json"); 150 + break; 151 + case "pt-BR": 152 + translations = require("../../public/locales/pt-BR/messages.json"); 153 + break; 154 + case "es-ES": 155 + translations = require("../../public/locales/es-ES/messages.json"); 156 + break; 157 + case "zh-TW": 158 + translations = require("../../public/locales/zh-TW/messages.json"); 159 + break; 160 + case "fr-FR": 161 + translations = require("../../public/locales/fr-FR/messages.json"); 162 + break; 163 + default: 164 + throw new Error(`No static translation file for locale: ${locale}`); 165 + } 166 + } 167 + } catch (loadError: any) { 168 + throw new Error( 169 + `Failed to load translations for ${locale}: ${loadError.message}`, 170 + ); 171 + } 172 + 173 + if (!translations || Object.keys(translations).length === 0) { 174 + throw new Error("No translations found in file"); 175 + } 176 + 177 + return translations; 178 + } catch (error: any) { 179 + console.error(`Failed to load translations for ${locale}:`, error); 180 + // Return minimal fallback 181 + return { 182 + loading: "Loading...", 183 + error: "Error", 184 + cancel: "Cancel", 185 + "settings-title": "Settings", 186 + }; 187 + } 188 + } 189 + 190 + // Initialize i18next with our configuration 191 + let initPromise: Promise<typeof i18next> | null = null; 192 + 193 + export default function initI18next(config: any = {}): Promise<typeof i18next> { 194 + // Return existing promise if already initializing 195 + if (initPromise) { 196 + return initPromise; 197 + } 198 + 199 + const finalConfig = { ...I18NEXT_CONFIG, ...config }; 200 + 201 + initPromise = i18next 202 + .use(initReactI18next) 203 + .use(Fluent) 204 + .use( 205 + resourcesToBackend((locale: string, namespace: string, callback: any) => { 206 + // Load translations using our manifest-based system 207 + loadTranslationData(locale) 208 + .then((translations) => callback(null, translations)) 209 + .catch((error) => callback(error, null)); 210 + }), 211 + ) 212 + .init(finalConfig) 213 + .then(() => i18next); 214 + 215 + return initPromise; 216 + } 217 + 218 + // Utility functions for language management 219 + export async function changeLanguage(locale: string): Promise<void> { 220 + storage.setItem("@streamplace/locale", locale); 221 + await i18next.changeLanguage(locale); 222 + } 223 + 224 + export function getCurrentLanguage(): string { 225 + return i18next.language || LOCALE; 226 + } 227 + 228 + export function getSupportedLocales(): string[] { 229 + return [...manifest.supportedLocales]; 230 + } 231 + 232 + export function getLanguageInfo(locale: string): any { 233 + return manifest.languages[locale] || null; 234 + } 235 + 236 + export function isLocaleSupported(locale: string): boolean { 237 + return manifest.supportedLocales.includes(locale); 238 + } 239 + 240 + // Auto-initialize i18next on module load 241 + // This ensures the instance is ready when used in providers 242 + initI18next().catch((error) => { 243 + console.error("Failed to auto-initialize i18n:", error); 244 + }); 245 + 246 + export { i18next };
+18 -1
js/components/src/i18n/index.ts
··· 21 21 export { default as Fluent } from "i18next-fluent"; 22 22 23 23 // Basic provider components for consistent setup 24 - export { I18nProvider, LanguageSelector } from "./provider"; 24 + export { I18nProvider } from "./provider"; 25 + 26 + // Bootstrap configuration and utilities 27 + export { 28 + I18NEXT_CONFIG, 29 + changeLanguage, 30 + getCurrentLanguage, 31 + getCurrentLocale, 32 + getLanguageInfo, 33 + getLocaleFromSystemLocale, 34 + getSupportedLocales, 35 + i18next, 36 + default as initI18next, 37 + isLocaleSupported, 38 + } from "./i18next-config"; 39 + 40 + // Manifest data 41 + export { default as manifest } from "../../locales/manifest.json"; 25 42 26 43 // TypeScript types 27 44 export type {
+2 -43
js/components/src/i18n/provider.tsx
··· 1 1 // Simple i18n provider wrapper for consistent setup 2 2 import type { i18n } from "i18next"; 3 - import React, { ReactNode } from "react"; 4 - import { I18nextProvider, useTranslation } from "react-i18next"; 3 + import { ReactNode } from "react"; 4 + import { I18nextProvider } from "react-i18next"; 5 5 6 6 interface I18nProviderProps { 7 7 children: ReactNode; ··· 15 15 export function I18nProvider({ children, i18n }: I18nProviderProps) { 16 16 return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>; 17 17 } 18 - 19 - /** 20 - * Basic language selector component 21 - */ 22 - export function LanguageSelector({ 23 - languages, 24 - showFlags = true, 25 - className, 26 - onChange, 27 - }: { 28 - languages: Array<{ 29 - code: string; 30 - name: string; 31 - nativeName?: string; 32 - flag?: string; 33 - }>; 34 - showFlags?: boolean; 35 - className?: string; 36 - onChange?: (language: string) => void; 37 - }) { 38 - const { i18n } = useTranslation(); 39 - 40 - const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => { 41 - const newLanguage = event.target.value; 42 - i18n.changeLanguage(newLanguage); 43 - if (onChange) { 44 - onChange(newLanguage); 45 - } 46 - }; 47 - 48 - return ( 49 - <select className={className} value={i18n.language} onChange={handleChange}> 50 - {languages.map((lang) => ( 51 - <option key={lang.code} value={lang.code}> 52 - {showFlags && lang.flag ? `${lang.flag} ` : ""} 53 - {lang.nativeName || lang.name} 54 - </option> 55 - ))} 56 - </select> 57 - ); 58 - }
+110 -7
js/docs/src/content/docs/guides/localizing/for-devs.md
··· 92 92 <p>{t('message-count', { count: 5 })}</p> 93 93 ``` 94 94 95 - 2. Add English translations (or others if you're proficient) to the .ftl files. 95 + 2. Add English translations (or others if you're proficient) to the .ftl files 96 + in `js/components/locales/`: 96 97 97 - ````fluent 98 - # Edit: js/app/src/i18n/locales/data/en-US/settings.ftl 98 + ```fluent 99 + # Edit: js/components/locales/en-US/settings.ftl 99 100 settings-title = Settings 100 101 message-count = { $count -> 101 102 [0] No messages 102 103 [1] You have one new message 103 104 *[other] You have { $count } new messages 104 - }``` 105 + } 106 + ``` 107 + 108 + 3. Compile the translations to JSON: 105 109 106 - 3. Compile and build the i18n files: 107 110 ```bash 108 - pnpm run i18n:build 109 - ```` 111 + cd js/components 112 + pnpm i18n:compile 113 + ``` 114 + 115 + This reads the `.ftl` files and outputs compiled JSON to 116 + `js/components/public/locales/{locale}/messages.json`. 117 + 118 + 4. For web: the compiled files in `public/locales/` are served as static assets 119 + and loaded on demand. 120 + 121 + For native: the compiled files are bundled with the app via static `require()` 122 + calls in the components package. 123 + 124 + ## Project structure 125 + 126 + The i18n system is centralized in `@streamplace/components`: 127 + 128 + ``` 129 + js/components/ 130 + ├── locales/ # Source .ftl files 131 + │ ├── en-US/ 132 + │ │ └── settings.ftl 133 + │ ├── pt-BR/ 134 + │ ├── es-ES/ 135 + │ ├── zh-TW/ 136 + │ └── fr-FR/ 137 + ├── src/i18n/ 138 + │ ├── manifest.json # Supported locales and metadata 139 + │ ├── i18next-config.ts # Bootstrap configuration 140 + │ ├── provider.tsx # React provider components 141 + │ └── index.ts # Public exports 142 + ├── public/locales/ # Compiled JSON output 143 + │ ├── en-US/ 144 + │ │ └── messages.json 145 + │ └── ... 146 + └── scripts/ 147 + ├── compile-translations.js 148 + └── extract-i18n.js 149 + ``` 150 + 151 + The app imports i18n from `@streamplace/components`: 152 + 153 + ```ts 154 + import { i18next, useTranslation } from "@streamplace/components"; 155 + ``` 156 + 157 + ## Available scripts 158 + 159 + In `js/components`: 160 + 161 + - `pnpm i18n:compile` - Compile .ftl files to JSON 162 + - `pnpm i18n:watch` - Watch .ftl files and recompile on changes 163 + - `pnpm i18n:extract` - Extract translation keys from source code (TODO: needs 164 + path updates) 110 165 111 166 ## Keep in mind... 112 167 ··· 122 177 button-save-changes = Save Changes 123 178 form-validation-email-invalid = Please enter a valid email address 124 179 ``` 180 + 181 + ### Platform differences 182 + 183 + The system handles both web and React Native: 184 + 185 + - **Web**: loads translations via HTTP from `/locales/{locale}/messages.json` 186 + - **React Native**: bundles translations via static `require()` calls 187 + 188 + The bootstrap code in `@streamplace/components/i18n` automatically detects the 189 + platform and uses the appropriate loading strategy. 190 + 191 + ### Adding new locales 192 + 193 + 1. Add the locale to `js/components/src/i18n/manifest.json`: 194 + 195 + ```json 196 + { 197 + "supportedLocales": [ 198 + "en-US", 199 + "pt-BR", 200 + "es-ES", 201 + "zh-TW", 202 + "fr-FR", 203 + "new-LOCALE" 204 + ], 205 + "languages": { 206 + "new-LOCALE": { 207 + "code": "new-LOCALE", 208 + "name": "Language Name", 209 + "nativeName": "Native Name", 210 + "flag": "🏁" 211 + } 212 + } 213 + } 214 + ``` 215 + 216 + 2. Create the locale directory and .ftl files in 217 + `js/components/locales/new-LOCALE/` 218 + 219 + 3. Add a static `require()` case in `js/components/src/i18n/i18next-config.ts`: 220 + 221 + ```ts 222 + case "new-LOCALE": 223 + translations = require("../../public/locales/new-LOCALE/messages.json"); 224 + break; 225 + ``` 226 + 227 + 4. Run `pnpm i18n:compile` to generate the JSON file 125 228 126 229 You can also 127 230 [view the official Fluent docs](https://github.com/projectfluent/fluent/wiki/Good-Practices-for-Developers)