ps-translate — Traducciones PrestaShop automatizadas (multi-idioma)
Detecta strings sin traducir y los traduce directamente usando Claude Code, sin API key externa. Agnóstico de idioma: detecta los idiomas instalados o los pregunta.
Flujo de trabajo
Paso 0 — Detectar theme e idiomas (SIEMPRE primero)
python3 ~/.claude/skills/ps-translate/scripts/detect.py --base <ruta_ps>
Devuelve el child theme, su parent, y los locales instalados:
{ "theme": "mystore", "parent": "classic",
"locales": ["es-ES", "fr-FR", "de-DE"], "source": "db" }
source: "db"→ conexión directa víapymysql(requiere el host de DB accesible desde donde corre el script — típico en instalaciones no-Lando o con DB expuesta).source: "db (lando)"→ cuandodatabase_hostes el nombre del servicio Docker interno (no resoluble desde el host), se usalando mysql <db> -e "..."en su lugar — esto SÍ ve los idiomas realmente activos enps_lang, no adivines porsource: "filesystem"que no hay forma de saberlo en un proyecto Lando.source: "filesystem"→ último recurso: deducido de qué dirstranslations/<locale>/ya existen. Solo dice qué locales YA tienen alguna traducción empezada, NO qué idiomas están realmente activos en la tienda — si el proyecto usa Lando y esto aparece, algo falló antes (revisa que.lando.ymlexista ylandoesté en el PATH).
Luego decide el/los idioma(s) objetivo:
- Si hay UN solo locale (aparte de
en-US) → úsalo directo. - Si hay VARIOS → pregunta al usuario a cuál(es) traducir (acepta varios).
- Si
localesviene vacío o el usuario quiere otro → pídele el locale (formatoes-ES,fr-FR,de-DE,it-IT,pt-PT…). No asumas es-CO ni ninguno por defecto.
Paso 1 — Escanear strings faltantes (por cada locale objetivo)
python3 ~/.claude/skills/ps-translate/scripts/scan.py \
--base <ruta_ps> --lang <locale> --output /tmp/ps_missing_<locale>.json
--theme se auto-detecta; pásalo solo para forzar otro. Argumentos:
| Flag | Default | Descripción |
|---|---|---|
--base |
. (cwd) |
Raíz de la instalación PS |
--lang |
auto si hay uno solo | Locale objetivo (ej. fr-FR) |
--theme |
auto-detectado | Child theme |
--theme-only |
false | Solo storefront (Shop.*), ignora módulos terceros — recomendado |
--domain |
todos | Filtrar por dominio (ej. ShopThemeCatalog) |
--output |
stdout | Ruta a fichero JSON de salida |
--include-admin |
false | Incluir dominios de backoffice |
--include-emails |
false | Incluir plantillas de email Twig (mails/themes/**/*.twig, dominio Emails.Body) — ver sección "Facturas PDF y emails" |
Consejo: empieza con
--theme-only. Sin él, el scan incluye TODOS los strings de módulos terceros (mailchimp, paypal, feeds…) que suelen ser miles y muchos ya vienen traducidos por el módulo.--theme-onlydeja solo lo que el cliente ve en la tienda.
Salida: {locale, theme, parent, total, domains: {"Shop.Theme.X": [strings...]}}.
Paso 2 — Traducir los strings (Claude lo hace aquí)
Con el JSON del scan, traduce cada dominio al locale objetivo (el que sea).
Reglas universales (cualquier idioma):
- Traduce a la variante regional del locale (
es-ESEspaña,es-COColombia,pt-PTvspt-BR, etc.). Español de España = usa "vosotros"/formal según tono del sitio; español LATAM = "ustedes", sin vosotros. - Nunca traduzcas placeholders:
%s,%d,{variable},[1]...[/1],%%param%%. - Strings cortos (botones, etiquetas) → 3-4 palabras máx.
- Mensajes de error → directo y claro.
- Terminología e-commerce coherente. Glosario según idioma:
- es: cart→carrito, order→pedido, shipping→envío, checkout→pago, discount→descuento
- fr: cart→panier, order→commande, shipping→livraison, checkout→paiement, discount→remise
- de: cart→Warenkorb, order→Bestellung, shipping→Versand, checkout→Kasse, discount→Rabatt
- it: cart→carrello, order→ordine, shipping→spedizione, checkout→pagamento, discount→sconto
- pt: cart→carrinho, order→encomenda, shipping→envio, checkout→pagamento, discount→desconto
- Otro idioma → aplica el equivalente estándar de e-commerce.
- Salida: JSON
{"Shop.Theme.X": {"source": "traducción", ...}}por dominio.
Paso 3 — Escribir los XLF (por cada locale)
python3 ~/.claude/skills/ps-translate/scripts/write_xlf.py \
--base <ruta_ps> --lang <locale> --translations <json_traducciones>
Escribe en themes/<theme>/translations/<locale>/<Domain>.<locale>.xlf, sin
duplicar entradas existentes. --theme auto-detectado. --dry-run para preview.
Paso 4 — Limpiar caché y verificar
rm -rf <base>/var/cache/prod/*
git add themes/<theme>/translations/
git commit -m "feat(i18n): auto-translate missing strings (<locale>) via ps-translate"
Multi-idioma en una corrida
Para traducir a varios locales, repite pasos 1-3 por cada uno:
for LANG in es-ES fr-FR de-DE; do
python3 ~/.claude/skills/ps-translate/scripts/scan.py \
--base . --lang "$LANG" --theme-only --output "/tmp/ps_missing_$LANG.json"
done
Traduce cada /tmp/ps_missing_<locale>.json con el glosario del idioma
correspondiente, y corre write_xlf.py una vez por locale.
Facturas PDF y emails transaccionales
Estos dos NO son un mecanismo aparte que necesite un sub-skill propio — son el
MISMO sistema de dominios/XLF que todo lo demás, solo que las plantillas viven
fuera de themes/** y modules/** (donde el scanner mira por defecto) y, en el
caso de emails, usan una sintaxis de traducción distinta (Twig en vez de Smarty).
Por eso está resuelto ampliando scan.py, no creando skills nuevas.
Facturas PDF — ya cubierto automáticamente, sin flag extra:
- Viven en la raíz
/pdf/**/*.tpl(fuera dethemes//modules/, por eso antes el scanner las pasaba por alto por completo — un--domain Shop.Pdfsin este fix solo encontraba strings de módulos de terceros con ese mismo dominio, ej.psgdpr, NO la factura real). - Usan
{l s='...' d='Shop.Pdf'}igual que el resto — un scan normal (con o sin--theme-only, ya queShop.Pdfempieza porShop.) ya las incluye. - Overrides de PDF a nivel child-theme (
themes/<theme>/pdf/**) ya estaban cubiertos por el glob dethemes/**/*.tplde siempre.
Emails transaccionales — requiere --include-emails, y ANTES de nada
comprueba qué motor de email usa el proyecto:
SELECT value FROM ps_configuration WHERE name = 'PS_MAIL_THEME';
modern/classic→ el proyecto usa el sistema Twig de PS 8/9 (mails/themes/<theme>/**/*.twig), agnóstico de idioma (un solo fichero sirve para todos los locales;localees una variable Twig). Los ficheros REALES por idioma queMail::send()usa (mails/<lang>/*.html+.txt) no existen hasta que se GENERAN desde esos layouts — ver "Generación de plantillas" más abajo, es un paso aparte, no basta con tener el XLF traducido.- Si el proyecto tiene una carpeta
mails/<lang>/*.html+.txtPOBLADA (ficheros reales, no soloindex.php), puede estar en el sistema LEGACY de plantillas duplicadas por idioma — en ese caso, antes de asumir que hace falta traducir esos ficheros, compara los nombres contramails/themes/<theme activo>/core/*.html.twig: si CADA fichero legacy tiene su equivalente Twig, el legacy está muerto/sin usar (el motor Twig activo tiene prioridad) y no hace falta tocarlo. Si encuentras ficheros legacy SIN equivalente Twig (típico de módulos que no migraron), esos sí se traducen duplicando el fichero completo por idioma — mecanismo manual, fuera del alcance de scan.py/write_xlf.py (no son strings sueltos, son ficheros enteros). - Las líneas de ASUNTO (
Emails.Subject) se fijan en PHP core ($translator->trans('...', [], 'Emails.Subject')dentro de clases comoSendProcessOrderEmailHandler), no en las plantillas — no son escaneables porscan.py. Vienen ya traducidas en el paquete de idioma OFICIAL de PrestaShop para ese locale; si ese paquete no está instalado (comprueba si existetranslations/<locale>/en la raíz del core, no solo en el theme), ni esto ni nada de este skill lo puede completar — es instalar el paquete oficial desde Admin → Internacional → Traducciones, no un gap de contenido que traducir a mano.
El dominio de emails va en CORE, no en el theme — excepción confirmada empíricamente
Emails.Body (y cualquier otro dominio referenciado desde una plantilla de email,
ver más abajo) se resuelve, durante prestashop:mail:generate, por un traductor
Symfony que NO lee themes/<theme>/translations/ en absoluto — probado
directamente: ni con el nombre de fichero con puntos (Emails.Body.fr-FR.xlf) ni
sin puntos (EmailsBody.fr-FR.xlf), colocado en la carpeta del theme, cambió nada;
solo funcionó al ponerlo en la raíz translations/<locale>/. Esto contradice la
norma general de este skill ("nunca tocar core") — es una excepción deliberada y
limitada SOLO a los dominios de email, confirmada comparando contra el paquete
oficial es-ES (translations/es-ES/EmailsBody.es-ES.xlf — sin puntos, exactamente
la convención MailsBodyProviderDefinition::FILENAME_FILTERS_REGEX = ['#EmailsBody*#']
del código fuente de PS9).
Un email puede referenciar MÁS de un dominio. Algunos layouts arman un %label%
traducido con un |trans() ANIDADO usando OTRO dominio (ej. order_conf.html.twig
construye %order_history_label% desde 'Order history and details'|trans({}, 'Shop.Theme.Customeraccount', locale)
dentro de los parámetros del |trans() exterior). Ese dominio interior TAMBIÉN
necesita su copia en core, aunque sea un dominio normalmente theme-scoped que ya
funciona bien en el storefront — el traductor de generación de emails no distingue,
trata todo lo referenciado desde dentro de una plantilla de email igual. Para
encontrar TODOS los dominios que hacen falta en core para un proyecto dado:
python3 ~/.claude/skills/ps-translate/scripts/scan.py --base <ruta_ps> --list-mail-domains
# → Emails.Body,Shop.Theme.Customeraccount (lista completa, separada por comas)
Flujo completo de emails (en orden)
# 1. Dominios necesarios en core para ESTE proyecto (varía por mail theme/módulos instalados)
DOMAINS=$(python3 ~/.claude/skills/ps-translate/scripts/scan.py --base <ruta_ps> --list-mail-domains)
# 2. Escanea + traduce + escribe cada dominio de email — con --core-domains "$DOMAINS"
# (si un dominio como Shop.Theme.Customeraccount YA está traducido en el theme por otro
# motivo, basta con copiar ese XLF ya existente a translations/<locale>/ con el nombre
# sin puntos — no hace falta re-traducirlo desde cero)
python3 ~/.claude/skills/ps-translate/scripts/scan.py --base <ruta_ps> --lang <locale> \
--include-emails --output /tmp/ps_emails_<locale>.json
# … traducir (Paso 2) …
python3 ~/.claude/skills/ps-translate/scripts/write_xlf.py --base <ruta_ps> --lang <locale> \
--translations <json_traducido> --core-domains "$DOMAINS"
# 3. Limpiar caché — rm -rf var/cache/* NO es suficiente, hace falta el comando
php bin/console cache:clear
# 4. Generar las plantillas reales por idioma (esto NO pasa solo)
php bin/console prestashop:mail:generate <mail-theme> <locale> --overwrite
# <mail-theme> = valor de ps_configuration.PS_MAIL_THEME (normalmente "modern")
Por qué el paso 4 es obligatorio y no opcional: los layouts Twig son la FUENTE,
no lo que Mail::send() usa en producción — ese método lee los ficheros estáticos
generados en mails/<locale>/. Si ese idioma nunca se generó (típico cuando el
idioma se activó por API/DB en vez del flujo normal de Admin → Idiomas → Añadir),
esa carpeta ni existe, y ningún XLF por sí solo la crea.
Verificación final — vuelve a correr el scan tras generar; debe dar 0:
python3 ~/.claude/skills/ps-translate/scripts/scan.py --base <ruta_ps> --lang <locale> \
--include-emails --output /dev/null # total: 0 en stderr = todo cubierto
Y opcionalmente, barrido de texto plano sobre los HTML generados buscando frases
en inglés que se sepa deberían estar traducidas (grep simple, sin <tags>), como
capa extra de confianza antes de dar el email por completo.
Qué dominios ignorar (admin, no críticos)
Por defecto el scanner omite Admin.*, psgdpr, ps_themecusto, steasybuilder,
ps_facebook, ps_accounts. Pasa --include-admin para incluirlos.
Dónde se guardan las traducciones
Por defecto, siempre en el child theme:
themes/<theme>/translations/<locale>/<DomainKey>.<locale>.xlf
Nunca se modifican el core (/translations/) ni el tema padre — excepto los
dominios de email (write_xlf.py --core-domains "...", ver sección de arriba),
que van a translations/<locale>/<DomainKeySinPuntos>.<locale>.xlf en la raíz del
core porque el traductor de generación de emails no lee el theme en absoluto. Es
la única excepción deliberada a esta norma, y está acotada a esos dominios
concretos — todo lo demás sigue yendo siempre al theme.
Notas / limitaciones
- Detección DB requiere
pymysql(pip install pymysql). Sin él o sin DB accesible, cae a filesystem — funciona igual, solo lista los locales con dir de traducciones ya generado. - Heurística "ya traducido": salta strings con diacríticos del idioma destino (á, ñ, ç, ü, ß…). Un string sin diacríticos ya escrito en destino (ej. francés "Sort by"→"Trier par" no, pero "Ajouter au panier" sí sin acento) puede re-listarse; es inofensivo (se traduce a sí mismo). El diff contra los XLF es el filtro principal.
- Dos bugs de comparación ya corregidos (no deberían reaparecer, pero documentados
por si algo similar vuelve a pasar): (1) un
|trans()anidado dentro de los parámetros de otro (típico en emails, ver arriba) hacía que una regex simple capturase el dominio INTERIOR en vez del exterior — se resolvió balanceando llaves manualmente (find_twig_trans_calls) en vez de una regex de un solo paso. (2) el contenido de<source>en un XLF ya escrito está XML-escapado en disco, pero los strings detectados en plantillas (used) no lo están — comparar ambos tal cual hacía que CUALQUIER string con<,>,"o&(todo el HTML de los emails) se reportara como "sigue faltando" incluso después de traducirlo correctamente. Se corrigió desescapando enload_translations()antes de comparar.