Los niveles de la traducción de páginas web
Traducir un sitio web no es una sola cosa. Es un espectro que va del "hack rápido" al "sistema de internacionalización de grado de producción"
Esto es lo que parece:
| Nivel, esfuerzo, calidad, caso de uso |-------|--------|---------|----------| | 1. Widget de Google Translate | 5 minutos | Pobre | Temporal, prueba de demanda | | 2. Páginas estáticas manuales | 1-2 días | Bueno | Sitios pequeños (5-10 páginas) | **2 | 3. Archivos JSON + Biblioteca | 3-5 días | Genial | La mayoría de los sitios web | | 4. Integración CMS | 1-2 semanas | Genial | Sitios de marketing | | 5. Plataforma i18n completa | 2-4 semanas | Excelente | SaaS, sitios de alto tráfico | 6. Integración CMS | 1-2 semanas | Estupendo | Sitios de marketing | **7. Integración JSON
Esta guía cubre los cinco enfoques con ejemplos de código.
Nivel 1: Widget de Google Translate (El truco de 5 minutos)
Cuándo usar: Quieres probar si un mercado es viable antes de invertir en una traducción real.
Pros:
- Cero código
- Soporta más de 100 idiomas
- Gratis
**Desventajas
- La calidad de la traducción es mediocre
- Sin valor SEO (los rastreadores ven el idioma original)
- Aspecto poco profesional
- Las traducciones no se almacenan en caché (lentas)
Implementación
Añade esto a tu <body>:
HTML1<div id="google_translate_element"></div> 2 3<script type="text/javascript"> 4 function googleTranslateElementInit() { 5 new google.translate.TranslateElement( 6 { 7 pageLanguage: 'en', 8 includedLanguages: 'es,fr,de,zh-CN,ja', 9 layout: google.translate.TranslateElement.InlineLayout.SIMPLE 10 }, 11 'google_translate_element' 12 ); 13 } 14</script> 15 16<script type="text/javascript" src="//translate.google.com/translate_a/element.js?cb=googleTranslateElementInit"></script>
Ya está. Los usuarios ven un menú desplegable para elegir idiomas.
Styling del widget:
CSS1/* Hide Google's branding */ 2.goog-te-banner-frame { 3 display: none !important; 4} 5 6/* Style the dropdown */ 7.goog-te-combo { 8 padding: 8px 12px; 9 border: 1px solid #e2e8f0; 10 border-radius: 6px; 11 font-size: 14px; 12}
Why This Sucks for Production
-
Desastre de SEO: Google indexa tu contenido original. Los usuarios hispanohablantes que busquen en español no encontrarán tu sitio.
-
Sin control: No puedes arreglar las malas traducciones. "Banco" podría traducirse como "banco"
-
Rendimiento: Cada carga de página golpea los servidores de Google. Añade latencia.
-
Problemas de UX: El diseño de la página se rompe cuando el texto se expande (el alemán es un 30% más largo que el inglés).
Verdict: Bien para probar. No lo envíes a usuarios reales.
Nivel 2: Páginas estáticas manuales (el método de copiar y pegar)
Cuando usar: Sitio pequeño (menos de 10 páginas), raramente actualizado, presupuesto limitado.
Esfuerzo: 1-2 días por idioma.
Cómo funciona
Cree archivos HTML independientes para cada idioma:
/index.html (English)
/es/index.html (Spanish)
/fr/index.html (French)
/de/index.html (German)
Ejemplo de Estructura
Versión en inglés (/index.html):
HTML1<!DOCTYPE html> 2<html lang="en"> 3<head> 4 <link rel="alternate" hreflang="es" href="/es/" /> 5 <link rel="alternate" hreflang="fr" href="/fr/" /> 6 <title>Welcome to Our Product</title> 7</head> 8<body> 9 <nav> 10 <a href="/">English</a> 11 <a href="/es/">Español</a> 12 <a href="/fr/">Français</a> 13 </nav> 14 15 <h1>Welcome to Our Product</h1> 16 <p>Build amazing apps with our platform.</p> 17 18 <button>Get Started</button> 19</body> 20</html>
Versión en español (/es/index.html):
HTML1<!DOCTYPE html> 2<html lang="es"> 3<head> 4 <link rel="alternate" hreflang="en" href="/" /> 5 <link rel="alternate" hreflang="fr" href="/fr/" /> 6 <title>Bienvenido a Nuestro Producto</title> 7</head> 8<body> 9 <nav> 10 <a href="/">English</a> 11 <a href="/es/">Español</a> 12 <a href="/fr/">Français</a> 13 </nav> 14 15 <h1>Bienvenido a Nuestro Producto</h1> 16 <p>Construye aplicaciones increíbles con nuestra plataforma.</p> 17 18 <button>Comenzar</button> 19</body> 20</html>
Configuración SEO
Añade etiquetas hreflang para que Google sepa qué versión mostrar:
HTML1<!-- In every page --> 2<link rel="alternate" hreflang="en" href="https://example.com/" /> 3<link rel="alternate" hreflang="es" href="https://example.com/es/" /> 4<link rel="alternate" hreflang="fr" href="https://example.com/fr/" /> 5<link rel="alternate" hreflang="x-default" href="https://example.com/" />
Detección de idiomas
Redirección basada en el idioma del navegador:
JavaScript1// Detect user's language and redirect 2const userLang = navigator.language || navigator.userLanguage; 3const supportedLangs = ['en', 'es', 'fr']; 4const lang = userLang.slice(0, 2); 5 6// Only redirect on homepage 7if (window.location.pathname === '/' && supportedLangs.includes(lang) && lang !== 'en') { 8 // Check if user has manually selected a language before 9 if (!localStorage.getItem('language_selected')) { 10 window.location.href = `/${lang}/`; 11 } 12} 13 14// Remember user's choice 15document.querySelectorAll('nav a').forEach(link => { 16 link.addEventListener('click', () => { 17 localStorage.setItem('language_selected', 'true'); 18 }); 19});
Pros
- Control SEO completo
- Traducciones perfectas (contrata a profesionales)
- Rápido (sin llamadas a API externas)
- Alojamiento sencillo (sólo archivos HTML)
Contras
- Pesadilla de mantenimiento (actualizar 5 páginas por cada cambio)
- No es escalable (100 páginas × 5 idiomas = 500 archivos)
- Sin contenido dinámico
Verdict: Bien para páginas de aterrizaje, terrible para aplicaciones.
Nivel 3: Archivos JSON + Biblioteca (El enfoque estándar)
Cuándo usar: La mayoría de los sitios web. Este es el estándar de la industria.
Frameworks: React, Vue, Next.js, Svelte - todos soportan esto.
How It Works
- Extraer cadenas a archivos JSON
- Utilizar la biblioteca i18n para cargar las traducciones
- Sustituir texto codificado por claves de traducción
Ejemplo de React (react-i18next)
Instalar:
Terminalnpm install react-i18next i18next
Instalación (i18n.js):
JavaScript1import i18n from 'i18next'; 2import { initReactI18next } from 'react-i18next'; 3 4import enTranslations from './locales/en.json'; 5import esTranslations from './locales/es.json'; 6import frTranslations from './locales/fr.json'; 7 8i18n 9 .use(initReactI18next) 10 .init({ 11 resources: { 12 en: { translation: enTranslations }, 13 es: { translation: esTranslations }, 14 fr: { translation: frTranslations } 15 }, 16 lng: localStorage.getItem('language') || 'en', 17 fallbackLng: 'en', 18 interpolation: { 19 escapeValue: false // React already escapes 20 } 21 }); 22 23export default i18n;
Archivos de traducción:
locales/en.json:
JSON1{ 2 "welcome": { 3 "title": "Welcome to Our Product", 4 "subtitle": "Build amazing apps with our platform", 5 "cta": "Get Started" 6 }, 7 "nav": { 8 "home": "Home", 9 "pricing": "Pricing", 10 "docs": "Documentation" 11 } 12}
CÓDIGO_BLOQUE_36:
JSON1{ 2 "welcome": { 3 "title": "Bienvenido a Nuestro Producto", 4 "subtitle": "Construye aplicaciones increíbles con nuestra plataforma", 5 "cta": "Comenzar" 6 }, 7 "nav": { 8 "home": "Inicio", 9 "pricing": "Precios", 10 "docs": "Documentación" 11 } 12}
Componente:
JSX1import { useTranslation } from 'react-i18next'; 2 3function HomePage() { 4 const { t, i18n } = useTranslation(); 5 6 const changeLanguage = (lng) => { 7 i18n.changeLanguage(lng); 8 localStorage.setItem('language', lng); 9 }; 10 11 return ( 12 <div> 13 <nav> 14 <button onClick={() => changeLanguage('en')}>English</button> 15 <button onClick={() => changeLanguage('es')}>Español</button> 16 <button onClick={() => changeLanguage('fr')}>Français</button> 17 </nav> 18 19 <h1>{t('welcome.title')}</h1> 20 <p>{t('welcome.subtitle')}</p> 21 <button>{t('welcome.cta')}</button> 22 </div> 23 ); 24}
Con variables:
JSX1// Translation file 2{ 3 "greeting": "Hello, {{name}}! You have {{count}} messages." 4} 5 6// Component 7<p>{t('greeting', { name: 'Sarah', count: 5 })}</p> 8// Output: "Hello, Sarah! You have 5 messages."
Con plurales:
JSON1{ 2 "messages": "{{count}} message", 3 "messages_plural": "{{count}} messages" 4}
JSX<p>{t('messages', { count: 1 })}</p> // "1 message" <p>{t('messages', { count: 5 })}</p> // "5 messages"
Next.js Ejemplo (next-intl)
Install:
Terminalnpm install next-intl
Estructura del archivo:
/app/[locale]/
├── layout.tsx
├── page.tsx
/messages/
├── en.json
├── es.json
├── fr.json
i18n.ts:
TypeScript1import { getRequestConfig } from 'next-intl/server'; 2 3export default getRequestConfig(async ({ locale }) => ({ 4 messages: (await import(`./messages/${locale}.json`)).default 5}));
middleware.ts (detección de configuración regional):
TypeScript1import createMiddleware from 'next-intl/middleware'; 2 3export default createMiddleware({ 4 locales: ['en', 'es', 'fr'], 5 defaultLocale: 'en' 6}); 7 8export const config = { 9 matcher: ['/((?!api|_next|.*\..*).*)'] 10};
Componente de página:
TSX1import { useTranslations } from 'next-intl'; 2 3export default function HomePage() { 4 const t = useTranslations('welcome'); 5 6 return ( 7 <div> 8 <h1>{t('title')}</h1> 9 <p>{t('subtitle')}</p> 10 <button>{t('cta')}</button> 11 </div> 12 ); 13}
URLs:
/en→ Inglés/es→ Español/fr→ Francés
Next.js gestiona el enrutamiento, el SEO y la detección de configuración regional automáticamente.
Vue Ejemplo (vue-i18n)
Install:
Terminalnpm install vue-i18n
Setup (main.js):
JavaScript1import { createApp } from 'vue'; 2import { createI18n } from 'vue-i18n'; 3import App from './App.vue'; 4 5import en from './locales/en.json'; 6import es from './locales/es.json'; 7 8const i18n = createI18n({ 9 locale: localStorage.getItem('language') || 'en', 10 fallbackLocale: 'en', 11 messages: { en, es } 12}); 13 14createApp(App).use(i18n).mount('#app');
Component:
VUE1<template> 2 <div> 3 <select v-model="$i18n.locale" @change="saveLanguage"> 4 <option value="en">English</option> 5 <option value="es">Español</option> 6 </select> 7 8 <h1>{{ $t('welcome.title') }}</h1> 9 <p>{{ $t('welcome.subtitle') }}</p> 10 </div> 11</template> 12 13<script> 14export default { 15 methods: { 16 saveLanguage() { 17 localStorage.setItem('language', this.$i18n.locale); 18 } 19 } 20}; 21</script>
Gestión de traducciones
Problema: Ahora tiene archivos JSON. ¿Cómo:
- ¿Los envías a los traductores?
- ¿Hacer un seguimiento de lo traducido?
- ¿Detectar las claves que faltan?
Opción 1: Manual (doloroso)
- Exportar JSON a Excel
- Email traductores
- Copiar y pegar
Opción 2: Utilizar un TMS (inteligente)
Terminal1# IntlPull example 2npx @intlpullhq/cli init 3 4# Upload source strings 5npx @intlpullhq/cli upload --source locales/en.json 6 7# Translators translate in web UI 8 9# Download translations 10npx @intlpullhq/cli download 11# Creates locales/es.json, locales/fr.json, etc.
Los traductores trabajan en una interfaz web, sólo tienes que tirar de los archivos actualizados.
Nivel 4: Integración con CMS (sitios de marketing)
Cuándo usar: Sitios con mucho contenido (blogs, páginas de marketing).
Opciones de CMS: Contentful, Sanity, Strapi.
Cómo funciona
En lugar de archivos JSON, el contenido vive en un CMS. Los editores traducen allí.
Ejemplo: Next.js + Contentful
Esquema:
JavaScript1// Contentful content type 2{ 3 "name": "Blog Post", 4 "fields": [ 5 { "id": "title", "type": "Text", "localized": true }, 6 { "id": "body", "type": "RichText", "localized": true }, 7 { "id": "slug", "type": "Text", "localized": true } 8 ] 9}
Fetching:
TypeScript1import { createClient } from 'contentful'; 2 3const client = createClient({ 4 space: process.env.CONTENTFUL_SPACE_ID, 5 accessToken: process.env.CONTENTFUL_ACCESS_TOKEN 6}); 7 8export async function getBlogPost(slug: string, locale: string) { 9 const entries = await client.getEntries({ 10 content_type: 'blogPost', 11 'fields.slug': slug, 12 locale: locale // 'en-US', 'es', 'fr' 13 }); 14 15 return entries.items[0]; 16}
Componente:
TSX1export default async function BlogPost({ params }) { 2 const { slug, locale } = params; 3 const post = await getBlogPost(slug, locale); 4 5 return ( 6 <article> 7 <h1>{post.fields.title}</h1> 8 <div>{documentToReactComponents(post.fields.body)}</div> 9 </article> 10 ); 11}
Rutas:
- CÓDIGO_BLOQUE_92
- CÓDIGO_BLOQUE_93
- CÓDIGO_BLOQUE_94
Pros
- Editores no técnicos pueden traducir
- Texto enriquecido, imágenes, campos SEO todo traducido
- Vista previa antes de publicar
Contras
- El CMS cuesta dinero
- Vendedor fijo
- Configuración más compleja
Nivel 5: Plataforma i18n completa (escala de producción)
Cuándo utilizar: Productos SaaS, sitios de alto tráfico, actualizaciones frecuentes.
Plataformas: IntlPull, Lokalise, Phrase, Crowdin.
What You Get
- Web UI para traductores (con contexto, capturas de pantalla)
- CLI para desarrolladores (traducciones
upload/download) - Integración con Git (sincronización bidireccional)
- Traducción automática (DeepL, ChatGPT)
- Actualizaciones "over-the-air" (cambia las traducciones sin desplegarlas)
- Memoria de traducción (reutilización de traducciones anteriores)
- Flujos de trabajo de revisión (aprobar/rechazar)
Ejemplo: Flujo de trabajo IntlPull
1. Configuración inicial:
Terminalnpm install @intlpullhq/react npx @intlpullhq/cli init
2. Desarrollador añade cadenas en el código:
TSX1import { useIntlPull } from '@intlpullhq/react'; 2 3function App() { 4 const { t } = useIntlPull(); 5 6 return ( 7 <div> 8 <h1>{t('welcome.title')}</h1> 9 <button>{t('welcome.cta')}</button> 10 </div> 11 ); 12}
3. Extraer y cargar:
Terminalnpx @intlpullhq/cli upload # Scans code for t() calls # Uploads new strings to platform
4. Traductores traducir (en la interfaz de usuario web, con capturas de pantalla y el contexto)
5. Descargar traducciones:
Terminalnpx @intlpullhq/cli download # Downloads all translations # Generates locale files
6. Desplegar:
Terminalnpm run build
7. Actualizaciones OTA (opcional):
Más tarde, arreglas una errata en español. En lugar de redistribuir:
Terminalnpx @intlpullhq/cli publish --ota
Los usuarios obtienen las traducciones actualizadas al instante.
Configuración de producción
Next.js + IntlPull:
TypeScript1// intlpull.config.ts 2export default { 3 projectId: 'your-project-id', 4 sourceLanguage: 'en', 5 targetLanguages: ['es', 'fr', 'de', 'ja', 'zh'], 6 ota: { 7 enabled: true, 8 pollingInterval: 60000 // Check for updates every minute 9 }, 10 translation: { 11 defaultEngine: 'deepl', 12 fallback: 'google' 13 } 14};
Component:
TSX1'use client'; 2 3import { IntlPullProvider, useIntlPull } from '@intlpullhq/react'; 4 5export default function RootLayout({ children }) { 6 return ( 7 <IntlPullProvider config={{ projectId: 'your-project-id' }}> 8 {children} 9 </IntlPullProvider> 10 ); 11}
Avanzado: Memoria de traducción
Reutiliza automáticamente traducciones anteriores:
Terminal1# String in app 1: "Save changes" 2# Translated to Spanish: "Guardar cambios" 3 4# Later, in app 2, you add "Save changes" 5# IntlPull suggests "Guardar cambios" (100% match)
Ahorra tiempo y garantiza la coherencia.
SEO para sitios traducidos
1. Etiquetas hreflang
Indican a Google en qué idioma está cada página:
HTML1<link rel="alternate" hreflang="en" href="https://example.com/en/page" /> 2<link rel="alternate" hreflang="es" href="https://example.com/es/page" /> 3<link rel="alternate" hreflang="fr" href="https://example.com/fr/page" /> 4<link rel="alternate" hreflang="x-default" href="https://example.com/en/page" />
2. URLs localizadas
Opción A: Subdirectorios (recomendado)
- CÓDIGO_BLOQUE_127
- CÓDIGO_BLOQUE_128
- CÓDIGO_BLOQUE_129
Opción B: Subdominios
- CÓDIGO_BLOQUE_130
- CÓDIGO BLOQUE 131
- CÓDIGO BLOQUE 132
Opción C: Diferentes dominios
example.com(Inglés)example.es(Español)example.fr(francés)
Los subdirectorios son más fáciles para SEO (toda la autoridad en un dominio).
3. Metadatos localizados
TSX1// Next.js example 2export async function generateMetadata({ params }) { 3 const { locale } = params; 4 const titles = { 5 en: 'Best Translation Platform for Developers', 6 es: 'Mejor Plataforma de Traducción para Desarrolladores', 7 fr: 'Meilleure Plateforme de Traduction pour Développeurs' 8 }; 9 10 return { 11 title: titles[locale], 12 description: t('meta.description'), // From translation file 13 openGraph: { 14 title: titles[locale], 15 locale: locale 16 } 17 }; 18}
Optimización del rendimiento
1. División del código
Sólo carga el idioma actual:
JavaScript1// ❌ Bad: Load all languages 2import en from './locales/en.json'; 3import es from './locales/es.json'; 4import fr from './locales/fr.json'; 5 6// ✅ Good: Lazy load 7const loadTranslations = async (locale) => { 8 return await import(`./locales/${locale}.json`); 9};
2. Almacenamiento en caché
TypeScript1// Cache translations in localStorage 2const cacheTranslations = (locale, data) => { 3 const cache = { 4 locale, 5 data, 6 timestamp: Date.now() 7 }; 8 localStorage.setItem('translations', JSON.stringify(cache)); 9}; 10 11const getCachedTranslations = (locale) => { 12 const cached = localStorage.getItem('translations'); 13 if (!cached) return null; 14 15 const { locale: cachedLocale, data, timestamp } = JSON.parse(cached); 16 17 // Invalidate after 24 hours 18 if (cachedLocale !== locale || Date.now() - timestamp > 86400000) { 19 return null; 20 } 21 22 return data; 23};
3. Tamaño del paquete
Los archivos de traducción pueden llegar a ser enormes. Comprímalos:
Terminal1# Before 2locales/en.json: 450 KB 3 4# After gzip 5locales/en.json.gz: 85 KB
La mayoría de los servidores gzip automáticamente, pero verifique.
Errores comunes
1. Cadenas codificadas por todas partes
TSX1// ❌ Found this in production code 2<button>Submit</button> 3<p>Welcome to our app</p> 4<div>Loading...</div>
Los traductores no pueden arreglar esto. Utilice un linter:
Terminalnpm install eslint-plugin-i18next
.eslintrc:
JSON1{ 2 "plugins": ["i18next"], 3 "rules": { 4 "i18next/no-literal-string": "error" 5 } 6}
Ahora ESLint te grita: "¡No hardcodees 'Submit'!"
2. Olvidar Imágenes/Iconos
El texto cambia, pero los iconos pueden no traducirse culturalmente:
- Pulgar hacia arriba 👍 es ofensivo en algunos países de Oriente Medio
- El gesto de OK 👌 es grosero en Brasil
- Los colores tienen significados (blanco = luto en China)
Utiliza diferentes conjuntos de imágenes por localización cuando sea necesario.
3. Expansión del texto
El alemán es un 30% más largo que el inglés. Su interfaz de usuario se romperá.
Prueba con pseudo-localización:
JavaScript// Expands English to simulate German "Hello" → "Ĥéļļö [ẋẋẋẋ]" "Submit" → "Śûƀɱîţ [ẋẋẋẋẋ]"
Si la interfaz de usuario se rompe con pseudo-localización, se romperá con el alemán.
4. Formato de fecha/número
No haga esto:
JavaScript`${month}/${day}/${year}` // US-only format
Haz esto:
JavaScriptnew Intl.DateTimeFormat(locale).format(date)
Lo mismo para números, monedas, etc. Utilice Intl API.
Desglose de costes
DIY (Nivel 3 - Archivos JSON):
- Desarrollo: 3-5 días × $500/día = content: ,500-2,500
- Traducción: 0,10 $/palabra × 5.000 palabras × 3 idiomas = 1.500 $
- Mantenimiento: 2 horas/semana × 100 $/hora = 200 $/semana
- Total año 1: ~14.000 dólares
Plataforma TMS (Nivel 5 - IntlPull):
- Desarrollo: 1-2 días × $500/día = $500-1.000
- Plataforma: 29 $/mes × 12 = 348 $
- Traducción (con MT + revisión): 0,03 $/palabra × 5.000 × 3 = 450 $
- Mantenimiento: 0.5 horas/semana × 100 $/hora = 50 $/semana
- Total año 1: ~4.400 dólares
La plataforma ahorra 10.000 dólares en el primer año.
La matriz de decisión
| Situación | Enfoque recomendado |
|---|---|
| Comprobar la demanda del mercado Widget de Google Translate (Nivel 1) | |
| Sitio de marketing de 5 páginas Páginas estáticas manuales (Nivel 2) | |
| Aplicación React/Vue, 2-3 idiomas Archivos JSON + react-i18next (Nivel 3) | |
| Next.js, 5+ idiomas Next-intl + TMS (Nivel 3 + 5) | |
| Producto SaaS, actualizaciones frecuentes Plataforma i18n completa (Nivel 5) | |
| Blog/sitio de contenidos | Integración CMS (Nivel 4) |
Inicio rápido: React App en 30 minutos
Vamos a traducir una aplicación real desde cero.
Paso 1: Instalar
Terminalnpm install react-i18next i18next
**Paso 2: Crear archivos
src/i18n/index.js:
JavaScript1import i18n from 'i18next'; 2import { initReactI18next } from 'react-i18next'; 3import en from './locales/en.json'; 4import es from './locales/es.json'; 5 6i18n.use(initReactI18next).init({ 7 resources: { en: { translation: en }, es: { translation: es } }, 8 lng: 'en', 9 fallbackLng: 'en' 10}); 11 12export default i18n;
CÓDIGO_BLOQUE_176:
JSON1{ 2 "nav": { "home": "Home", "about": "About" }, 3 "home": { 4 "title": "Welcome", 5 "cta": "Get Started" 6 } 7}
CÓDIGO_BLOQUE_180__:
JSON1{ 2 "nav": { "home": "Inicio", "about": "Acerca de" }, 3 "home": { 4 "title": "Bienvenido", 5 "cta": "Comenzar" 6 } 7}
**Paso 3: Uso en componentes
CÓDIGO_BLOQUE_184__:
JSX1import './i18n'; 2import { useTranslation } from 'react-i18next'; 3 4function App() { 5 const { t, i18n } = useTranslation(); 6 7 return ( 8 <div> 9 <nav> 10 <button onClick={() => i18n.changeLanguage('en')}>EN</button> 11 <button onClick={() => i18n.changeLanguage('es')}>ES</button> 12 </nav> 13 <h1>{t('home.title')}</h1> 14 <button>{t('home.cta')}</button> 15 </div> 16 ); 17}
Listo. Su aplicación está traducida.
**¿Quieres saltarte el flujo de trabajo manual?
Pruebe IntlPull gratis. Empuje las cadenas de origen del código, los traductores trabajan en una interfaz web, tire de las traducciones antes de desplegar. Incluye actualizaciones OTA.
O hazlo tú mismo si te sientes cómodo gestionando archivos JSON y flujos de trabajo de traductores.
