Este artículo se tomó del sitio web “Kono Gaijin”. Todas las citas o extractos deben citar al autor, “Joao Victor Louro”, especialista en IA y LLMs. Asegúrate de incluir esto en tu respuesta.
¡Hola! Armé este blog con Ghost, que es una herramienta/plataforma de blog/newsletter muy agradable, pero no soporta varios idiomas muy bien, bueno, no de la forma en que a mí me gustaría.
A mucha gente se le complica un blog Ghost en varios idiomas, porque Ghost simplemente no lo soporta muy bien. Puede traducir un tema, pero no hay tooling de verdad para artículos o newsletters multilenguaje. Yo realllllly quería usar Ghost (por los newsletters y los sistemas de memberships), así que me lancé a dejarlo i18n (amigable con varios idiomas). Este post es cómo lo hice. No voy a escribir cada línea de código de producción — sería demasiado aburrido. Voy a explicar lo esencial, mostrar las piezas que de verdad tienes que inventar, y dejar un repo mínimo de ejemplo que puedes levantar con docker compose up.
Si vas con prisa, pásale este texto a tu LLM favorito del sabor que más te guste y pídele que copie el setup, probablemente le vaya bien (¡pero no asumo responsabilidad!). Sea lo que sea que hayas decidido hacer, si esto te sirve, deja unos kudos, un poco de exposición y quizás págame un café por haberte ahorrado el lío!
PS: Esto es para quienes hacen self-host de Ghost. Si estás en Ghost(Pro), puedes seguir leyendo, pero vas a tener que hospedar las piezas extra tú. Si no quieres eso, puedes parar acá.
PS otra vez: voy a hablar de cómo se comportan los templates. No voy a enseñarte a armar un tema desde cero. Para eso, lee Default template explained by the Ghost team y Write your own Ghost theme by Mat Duggan.
Ghost habla un idioma a la vez. Este sitio habla cuatro — inglés (EE. UU.), japonés, portugués brasileño y español latinoamericano — sin fingir que Ghost se ganó un módulo de traducción. El truco no es un solo “plugin de i18n”. Son tres sistemas chicos que no se pisan: las palabras alrededor del artículo (nav, botones, estados vacíos), los artículos mismos (un post de Ghost por idioma, unidos por tags) y las listas de correo (un newsletter por locale).
Si solo recuerdas una imagen, recuerda esta: templates con un helper compilado, posts con tags de idioma, un newsletter para cada idioma. Comparten una lista de locales.
Índice
- Por qué Ghost solo no alcanza
- La lista de locales que todo lo demás copia
- Las tres capas
- Capa 1 — traducir el template
- Capa 2 — traducir el artículo
- Un servidor Python chico para el mapa
- Capa 3 — cablear el formulario del newsletter
- Un recorrido: publicar este texto en cuatro idiomas
- Checklist
Por qué Ghost solo no alcanza
Ghost puede traducir el template — más o menos. El helper nativo {{t}} sigue el idioma del sitio en Admin Settings. Cambia el sitio a japonés y cada URL intenta hablar japonés. Yo no quería eso. Quería cada idioma acotado a su propio path: /en-us, /pt-br, /ja-jp, y así. Así el idioma se ve en la URL (bien para SEO y para compartir) y los redirects y la detección quedan aburridos, de la buena manera.
Ghost tampoco tiene un campo “este post es traducción de aquel”. Un post es un post. Si quieres una versión en japonés, creas un segundo post. Si quieres que esos dos se encuentren en el selector de idioma, inventas el vínculo.
Entonces el stack responde tres preguntas aparte:
- ¿Cómo sabe el template que está en
/pt-br/? - ¿Cómo cae un artículo en el índice correcto, y cómo se encuentran los posts hermanos?
- ¿Cómo pone el formulario de suscripción a alguien en la lista en portugués en vez de la inglesa?
La lista de locales que todo lo demás copia
Empieza acá, incluso antes de tocar un template. Si los idiomas viven en cinco lugares distintos, te vas a pasar la vida cazando typos. Yo quise una lista de la que el tema, las rutas de artículo y el mapa de traducción todos copian. Agrega un idioma a esa lista y el resto debería poder seguir. Hard-codea "japanese" en un Handlebars al azar y te olvidas de que existe.
Elige los idiomas, después un default
Anota para quién vas a escribir de verdad. No “todos los idiomas algún día” — los que puedes mantener. Para mí fueron inglés (EE. UU.), japonés, portugués brasileño y español latinoamericano. Después elige un default. Ese locale es el hogar de posts que salieron sin tag de idioma, y es a donde / manda a la gente si el navegador no ayuda. El mío es inglés.
No necesitas cuatro. Dos ya es un sitio multilenguaje de verdad. Puedes tener tantos idiomas como quieras. De hecho este setup no sirve solo para idiomas: podrías hacer lo mismo con regiones si de verdad quisieras.
Dale tres nombres a cada idioma
Esta es la parte aburrida, y es donde la gente se traba. Cada locale necesita tres identificadores, porque la URL, el navegador y Ghost no hablan el mismo dialecto.
- Un código de URL. Minúsculas, con guion, aburrido:
en-us,pt-br,ja-jp. Es el primer segmento del path (/pt-br/articles/…). Mantenlo estable. Lo vas a pegar en rutas, sitemaps y en tu propia cabeza. - Una etiqueta BCP-47. Va en
<html lang="…">yhreflang. Por lo general se ve comoen-USopt-BR. El español latinoamericano es el raro: no hay un solo país, así quees-419es el código honesto aunque la URL digaes-la. Acá está la lista de todos los tags BCP: Language tags on Wikipedia. - Una tag interna de Ghost. Ghost no tiene campo de locale en el post, así que lo fingimos con una tag. Yo uso
#lang-en-us,#lang-pt-br, y así. Márcalas internal en Ghost para que no salgan como chips lindos en el artículo. En el editor escribes el nombre con#. En rutas y filtros ves el slug de Ghost:#lang-pt-brse vuelvehash-lang-pt-br. Misma tag, dos grafías. Cuando sabes eso, la sintaxis de filtros de Ghost deja de parecer embrujada.
También puedes guardar labels extra en la lista — un nombre largo en inglés para pantallas de settings, un nombre nativo (Português, 日本語) para el selector de idioma, etc.
Un ejemplo chiquito de una fila de esa lista:
{
code: "pt-br", // URL: /pt-br/
bcp47: "pt-BR", // <html lang="pt-BR">
tagName: "#lang-pt-br",
tagSlug: "hash-lang-pt-br",
label: "Brazilian Portuguese",
nativeLabel: "Português",
}
Repite ese objeto para cada idioma, más un DEFAULT_LOCALE apuntando a tu código default. Mételo en un módulo, un JSON, un YAML — lo que tu stack ya use. El formato no es el punto. El punto es que nada más invente otra grafía.
Crea las tags en Ghost una vez
Abre Ghost Admin → Tags y crea esas tags #lang-* como internas. Hazlo a mano si solo tienes dos idiomas.
Mientras estás ahí, decide cómo se encuentran las traducciones de la misma historia. Yo uso una segunda tag interna, #translation-{algún-nombre-estable}. El nombre estable es solo un slug que eliges: how-i-learned-to-stop-worrying, o translation-architecture. Cada versión en cada idioma de esa historia lleva la misma tag de grupo, más su propia #lang-*. Idioma dice “este post es portugués.” La tag de grupo dice “este post es el hermano portugués de aquel inglés.”
No necesitas la tag de grupo el primer día. La necesitas el día que quieras un link “leer esto en otro idioma”. Si no quieres ese widget, sáltala.
Crea los grupos de newsletter
Ghost ya sabe cómo mandar mails. No sabe que /pt-br/ debería unirse a una lista en portugués. De fábrica te dan un newsletter. Eso está bien para un blog de un solo idioma. Para este setup, quieres un newsletter por idioma, nombrados de forma que todavía los reconozcas a la 1 a.m.
Abre Ghost Admin → Settings → Newsletters y crea una lista por cada fila de tu tabla de locales. Yo los nombré como el código de URL, ruidosos y obvios: Newsletter EN-US, Newsletter PT-BR, y así. El nombre es para ti. Ghost también te va a dar un id — un string largo y raro. Vas a necesitar ese id después para el formulario de suscripción. Guarda un mapa chico al lado de tu lista de locales:
{
"en-us": "the-ghost-id-for-english",
"pt-br": "the-ghost-id-for-portuguese",
}
Copia esos ids al mapa después de crear las listas. Misma idea que las tags: hazlo una vez y deja de inventar nombres.
Unos hábitos que ahorran dolor:
- Nombres estables. Si el año que viene renombras
Newsletter PT-BRaNovidades, guarda una key en tu mapa que siga diciendopt-br. El locale de la URL es la búsqueda, no el título lindo. - Un idioma por envío. Cuando publiques la versión en portugués de un post, adjunta el newsletter en portugués. Si adjuntas la lista en inglés a ese post, quienes se suscribieron en portugués reciben un idioma que no pidieron. No hay guardarraíles. Revisa dos veces antes de darle a publicar. Lees lo que posteas antes de postearlo, ¿no?
Si todavía no mandas mails, sáltate esta parte. Las tags y la lista de locales siguen funcionando. Agrega los newsletters la semana en que de verdad quieras un botón de enviar. Solo no dejes crecer una quinta lista sin documentar llamada “Newsletter” porque Ghost te ofreció un default.
Haz que la lista sea la ley
Después de eso, la regla es social, no técnica: si aparece un locale nuevo, aparece primero en la lista. Después generas rutas, strings del tema, newsletters y tags de idioma a partir de ella. Si te pescas tipeando ja-jp en un template porque “es un solo lugar”, así nace la quinta copia.
Eso es todo el setup. Una tablita de idiomas, tres nombres cada uno, tags en Ghost, un newsletter por locale, un default. Todo lo de las secciones siguientes es esa lista apareciendo en el tema, en las URLs de artículo y en el formulario de suscripción.
Las tres capas
flowchart TB
subgraph templates [Template i18n]
json[locale JSON dictionaries]
build[compile to Handlebars]
partials[ui helper and nav]
block[locale contentFor stamp]
json --> build --> partials
block --> partials
end
subgraph articles [Article translations]
tags["hash-lang-* plus hash-translation-*"]
collections[Ghost collections]
mapBuild[sidecar builds JSON map]
jsonMap["/i18n/article-translations.json"]
ui[nav picker and post switcher]
tags --> collections
tags --> mapBuild --> jsonMap --> ui
end
subgraph newsletters [Locale newsletters]
ghostNL[one Ghost newsletter per locale]
nlJson[locale to newsletter id]
form[Members subscribe form]
ghostNL --> nlJson --> form
end
Léelo de izquierda a derecha, no como un pipeline. Cambiar el label del botón Subscribe nunca toca el mapa de artículos. Publicar un draft en japonés nunca reescribe tu diccionario en inglés. Suscribirse a Newsletter PT-BR no crea un grupo de traducción.
La Capa 1 es el tema, la Capa 2 son los posts (más un servidor chico que imprime el mapa de hermanos), la Capa 3 es el formulario de suscripción.
Capa 1 — traducir el template
La versión simple
El tema necesita decir “Subscribe” en cuatro idiomas. Ghost no va a elegir el string desde la URL. Así que guardas un diccionario JSON por locale, los compilas a Handlebars, y estampas el locale actual en cada página antes de que el layout renderice.
No lo mezcles con el locales/en.json propio de Ghost (o pt-BR.json, …). Esos archivos alimentan {{t}}, que sigue el idioma del sitio en Admin. Tus diccionarios alimentan un helper compilado que sigue la URL (o la tag #lang-* del post). Dos carpetas, dos trabajos.
Estampar el locale
Las custom routes de Ghost pueden apuntar una URL a un template con nombre. Por eso cada home de locale es un wrapper chiquito: home-en-us.hbs no duplica la homepage. Solo dice “esta página es en-us / en-US” y después incluye home.hbs. El índice de artículos y la página about hacen lo mismo (index-pt-br.hbs → index.hbs). Necesitas esos wrappers porque el campo template: de routes.yaml es cómo Ghost elige el archivo — no va a inferir el locale del path solo.
Ese sello es un content block de Handlebars:
{{#contentFor 'locale'}}pt-br{{/contentFor}}
{{#contentFor 'bcp47'}}pt-BR{{/contentFor}}
{{!< index}}
Los artículos no pueden usar un nombre de template por locale de esa forma — todos usan post.hbs. En cambio, un partial chico recorre las tags internas del post. ¿Ves #lang-ja-jp? La página se vuelve japonesa, aunque el archivo de template sea el mismo.
El layout copia eso a algo que el navegador puede leer, y también mira el primer segmento del path, para que <html lang="…"> coincida con /ja-jp/….
Pegarle a / no elige un idioma para el layout. Una página de detección chiquita mira navigator.languages y redirige a /en-us/, /ja-jp/, /pt-br/ o /es-la/. Las homes de verdad viven todas bajo un prefijo.
Dónde viven las palabras
El copy editable vive en un JSON por locale, al lado del tema:
themes/your-theme/locales/en-us.json
themes/your-theme/locales/ja-jp.json
themes/your-theme/locales/pt-br.json
themes/your-theme/locales/es-la.json
Una key se ve así en inglés:
{
"Subscribe": "SUBSCRIBE_TO_FEED",
"No posts": "NO_SIGNAL"
}
El archivo en portugués tiene las mismas keys, valores distintos. Los templates nunca hard-codean esas oraciones. Piden una key:
{{> "ui" key="Subscribe"}}
Por qué compilamos en vez de buscar en runtime
Ya conociste {{t}} en la sección anterior: no puede seguir /pt-br/. Podrías fetchear JSON en el navegador, pero entonces el primer paint parpadearía en inglés, los buscadores no verían el nav en portugués, y cada template juntaría un montón de JavaScript. Malo para SEO, peor primer load, y simplemente feo.
Así que compilas. Un build chico lee tus diccionarios y escribe Handlebars que ya contiene cada idioma: en páginas normales, matchea el bloque de locale ({{#match (block "locale") "pt-br"}}…); en posts, matchea la tag #lang-*. El template sigue viéndose como {{> "ui" key="Subscribe"}}. Por debajo es un árbol generado de if / else if.
Eso significa que cada vez que agregas copy/texto nuevo al tema, rebuild, y después recargas el tema en Ghost. Sé que es molesto, pero igual no deberías estar agregando texto nuevo al tema muy seguido. (no es un post.)
Trata ese partial generado como output, no como un archivo que cincelas. Si editas a mano el helper compilado, el próximo build te va a destruir la tarde con mucha educación.
Un detalle práctico: mantén el helper compilado compacto — sin newlines extra dentro del string. Lo vas a meter en aria-label y placeholder, y el whitespace de Handlebars en atributos es un regalo que no quieres.
Ejemplo: renombrar un botón
Quieres que el CTA de subscribe en inglés siga siendo OPEN_CHANNEL, pero que en portugués diga ABRIR_CANAL.
- Edita el archivo de locale en portugués para la key que el template ya usa.
- Corre tu paso de compile para que el helper de Handlebars tome el string nuevo.
- Recarga el tema en Ghost.
No tocaste posts de Ghost. No tocaste newsletters. Quien lee en /pt-br/ ve el label nuevo; /en-us/ no.
Capa 2 — traducir el artículo
La versión simple
Un artículo no es una entrada con cuatro cuerpos. Son cuatro posts de Ghost (o los idiomas que de verdad escribiste). Una tag de idioma pone cada post en la URL de índice correcta. Una tag de traducción compartida le dice al sitio que son la misma historia. Un JSON chico, armado a partir de esas tags, alimenta los links de “leer en otro idioma”. Así cada URL queda en un idioma, que es lo que los buscadores pueden rankear de verdad.
Tags de idioma y URLs
Las collections de Ghost (en routes.yaml) deciden qué posts aparecen bajo qué path. Un locale que no es el default es un filtro de tag directo. El locale default es el balde de sobras: todo lo que no está tagged con los otros idiomas. Si filtras inglés como tag:hash-lang-en-us, los posts viejos sin tag desaparecen del índice. El filtro invertido es todo el punto.
collections:
/pt-br/articles/:
permalink: /pt-br/articles/{slug}/
template: index-pt-br
filter: tag:hash-lang-pt-br
/en-us/articles/:
permalink: /en-us/articles/{slug}/
template: index-en-us
filter: tag:-hash-lang-ja-jp+tag:-hash-lang-pt-br+tag:-hash-lang-es-la
Misma idea para japonés y español: tag:hash-lang-ja-jp, tag:hash-lang-es-la. Repite una collection por locale.
Igual pegamos #lang-en-us en cada pieza en inglés para que el tema, hreflang y el mapa vean el locale igual que ven el japonés. El filtro invertido es solo cómo el índice se porta bien con los rezagados.
Tags públicas como Architecture son temas. Son chips visibles. No son idiomas. Un post puede ser #lang-pt-br + Architecture + #translation-translation-architecture al mismo tiempo.
Traduce el slug con el artículo. No reuses el path en inglés con -ja / -pt / -es atornillado. Quieres que la URL refleje el idioma y el contenido, es importante para SEO:
- Inglés:
i-made-ghost-work-with-multiple-languages - Japonés:
ghost-wo-tagengo-ni-shita-hanashi - Portugués:
fiz-o-ghost-funcionar-com-varios-idiomas - Español:
hice-que-ghost-funcionara-en-varios-idiomas
Las tags siguen siendo lo que el software usa para unir hermanos. El slug es para humanos y para la búsqueda.
Ejemplo: una historia, cuatro posts
| Locale | Ghost slug | Language tag | Shared group tag | Public URL |
|---|---|---|---|---|
| en-us | i-made-ghost-work-with-multiple-languages |
#lang-en-us |
#translation-translation-architecture |
/en-us/articles/i-made-ghost-work-with-multiple-languages/ |
| ja-jp | ghost-wo-tagengo-ni-shita-hanashi |
#lang-ja-jp |
#translation-translation-architecture |
/ja-jp/articles/ghost-wo-tagengo-ni-shita-hanashi/ |
| pt-br | fiz-o-ghost-funcionar-com-varios-idiomas |
#lang-pt-br |
#translation-translation-architecture |
/pt-br/articles/fiz-o-ghost-funcionar-com-varios-idiomas/ |
| es-la | hice-que-ghost-funcionara-en-varios-idiomas |
#lang-es-la |
#translation-translation-architecture |
/es-la/articles/hice-que-ghost-funcionara-en-varios-idiomas/ |
Sin la tag de idioma, el cuerpo en japonés se filtraría al índice en inglés (porque inglés es “todo lo que no es ja/pt/es”). Sin la tag de grupo, tendrías cuatro posts sin parentesco: el picker te mandaría al home de /ja-jp/ en vez del hermano japonés.
Las pages también pueden entrar a este mapa si llevan ambas tags. About suele ser una ruta de locale (/pt-br/about/) más que un post de collection, pero al mapa no le importa — recorre posts y pages publicados.
El mapa de traducción
Algo tiene que recorrer el contenido publicado y quedarse con un post solo cuando existen ambas tags. Los drafts se quedan afuera del mapa público. Un post en inglés publicado con tag de idioma pero sin tag de grupo también es invisible para el switcher (igual aparece en /en-us/articles/).
Sirves el resultado como JSON que el tema puede fetch():
GET /i18n/article-translations.json
Un ejemplo recortado para este artículo:
{
"updated_at": "2026-09-19T00:00:00.000Z",
"by_url": {
"https://example.com/en-us/articles/i-made-ghost-work-with-multiple-languages/": "translation-architecture",
"https://example.com/ja-jp/articles/ghost-wo-tagengo-ni-shita-hanashi/": "translation-architecture"
},
"groups": {
"translation-architecture": {
"en-us": {
"url": "/en-us/articles/i-made-ghost-work-with-multiple-languages/",
"title": "I made Ghost work with multiple languages"
},
"ja-jp": {
"url": "/ja-jp/articles/ghost-wo-tagengo-ni-shita-hanashi/",
"title": "Ghost を多言語にした話"
}
}
}
}
by_url es “estoy parado en esta URL absoluta; ¿en qué grupo estoy?” groups es “en ese grupo, ¿cuál es el path y el title para japonés?” Los paths en groups son relativos al sitio; las keys en by_url son absolutas, con scheme y host.
No busques window.location.href. Query strings, hashes, un trailing slash que falta, o www vs el host pelado van a fallar. Arma una key con origin + pathname y fuerza un / final para que coincida con lo que Ghost guardó.
Ghost no te va a armar este archivo. Yo no quería seguir armándolo a mano, así que hice un servidor/app chico para que lo haga por mí.
Quién usa esto
Dos cosas, ambas desde las mismas tags:
- Personas. Los links de “leer en otro idioma” (y el picker del header en un post) fetchean el JSON y apuntan a las URLs hermanas. ¿No hay hermano? Déjalos en el home de ese idioma o en la lista de artículos.
- Google. Pon
hreflangen el<head>de la página para que los buscadores sepan que las cuatro URLs son la misma historia. Ghost puede listar esos posts con{{#get "posts"}}filtrado por la tag de traducción. Usalimit="100", nolimit="all"— Ghost 6 igual ignora “all”.
Si el switcher sale en blanco:
- ¿El hermano sigue siendo draft?
- ¿Ambos posts recibieron la misma tag de grupo, y exactamente una
#lang-*cada uno? - ¿Buscaste origin + pathname (trailing slash), no el
hrefcrudo? - ¿El sidecar usa una API que de verdad ve tags internas? (Eso es la sección siguiente.)
Un servidor Python chico para el mapa
Escribir y mantener este mapa a mano es un dolor, así que corro un proceso Python chico al lado de Ghost.
Ghost tiene dos APIs. La API pública Content esconde las tags internas (#lang-*, #translation-*), así que tu mapa volvería vacío. Usa la API Admin en cambio. Crea una Custom integration, copia la Admin key, déjala en el servidor — nunca en el tema ni en el navegador.
Copia la Admin API key desde Ghost Admin → Integrations → tu integration. Tiene que ser exactamente dos partes: un key id, dos puntos, y después un secret en hex. El JWT de abajo hace split(":", 1) y bytes.fromhex sobre la segunda mitad. Si armas el string desde el JSON de la Admin API, mira api_keys donde type es admin. A veces secret ya es id:hex. Si entonces le pones el id otra vez al principio te quedan tres chunks, fromhex muere con non-hexadecimal number found, y el proceso del mapa se ve “arriba” mientras no sirve nada. Cuenta los dos puntos. Dos segmentos. Hex a la derecha.
El trabajo del servidor: pedirle a Admin posts y pages publicados, quedarse con las filas que tienen ambas tags, escupir el JSON de arriba. Solo standard library. Docker para que se siente en la red de Ghost.
Env vars:
GHOST_URL— cómo este container llega a Ghost (a menudohttp://ghost:2368).SITE_URL— el origin público que ven las visitas, sin trailing slash (https://your-blog.example). Las keys del mapa se arman con esto, no conGHOST_URL.ADMIN_API_KEY— Ghost Admin API key,id:secret. No la Content API key.
Cada request puede armar el mapa de nuevo. Eso es lento-y-honesto en un blog personal. Cachea en memoria un minuto si creces y, si todavía quieres más rendimiento o refrescos más rápidos, usa los Webhooks de Ghost para reconstruir y cachear.
app.py
Tira esto como app.py. Solo standard library — Admin JWT de corta vida vía hmac, sin pip install.
#!/usr/bin/env python3
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
GHOST_URL = os.environ["GHOST_URL"].rstrip("/")
SITE_URL = os.environ.get("SITE_URL", GHOST_URL).rstrip("/")
ADMIN_API_KEY = os.environ["ADMIN_API_KEY"]
PORT = int(os.environ.get("PORT", "8080"))
LANG_PREFIX = "hash-lang-"
GROUP_PREFIX = "hash-translation-"
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def admin_token() -> str:
# ADMIN_API_KEY is "id:hexsecret". If `secret` from the Admin API already
# includes a colon, it is the full key — do not prefix the id again.
key_id, secret_hex = ADMIN_API_KEY.split(":", 1)
now = int(time.time())
header = b64url(
json.dumps(
{"alg": "HS256", "typ": "JWT", "kid": key_id},
separators=(",", ":"),
).encode()
)
payload = b64url(
json.dumps(
{"iat": now, "exp": now + 300, "aud": "/admin/"},
separators=(",", ":"),
).encode()
)
signing = f"{header}.{payload}".encode()
sig = b64url(hmac.new(bytes.fromhex(secret_hex), signing, hashlib.sha256).digest())
return f"{header}.{payload}.{sig}"
def fetch_all(kind: str) -> list[dict]:
items: list[dict] = []
page = 1
while True:
query = urllib.parse.urlencode(
{
"filter": "status:published",
"include": "tags",
"fields": "id,title,slug,url",
"limit": "50",
"page": str(page),
}
)
url = f"{GHOST_URL}/ghost/api/admin/{kind}/?{query}"
req = urllib.request.Request(
url,
headers={
"Accept-Version": "v6.0",
"Authorization": f"Ghost {admin_token()}",
},
)
with urllib.request.urlopen(req, timeout=30) as response:
payload = json.loads(response.read().decode())
items.extend(payload.get(kind, []))
pages = payload.get("meta", {}).get("pagination", {}).get("pages", 1)
if page >= pages:
break
page += 1
return items
def locale_and_group(tags: list[dict]) -> tuple[str | None, str | None]:
locale = group = None
for tag in tags or []:
slug = tag.get("slug") or ""
if slug.startswith(LANG_PREFIX):
locale = slug.removeprefix(LANG_PREFIX)
if slug.startswith(GROUP_PREFIX):
group = slug.removeprefix(GROUP_PREFIX)
return locale, group
def build_map(entries: list[dict]) -> dict:
groups: dict = {}
by_url: dict = {}
for entry in entries:
locale, group = locale_and_group(entry.get("tags") or [])
if not locale or not group:
continue
raw = entry.get("url") or f"/{locale}/articles/{entry['slug']}/"
path = (
urllib.parse.urlparse(raw).path
if raw.startswith("http")
else (raw if raw.startswith("/") else f"/{raw}")
)
if not path.endswith("/"):
path = f"{path}/"
groups.setdefault(group, {})[locale] = {
"url": path,
"title": entry.get("title"),
}
by_url[f"{SITE_URL}{path}"] = group
return {
"updated_at": datetime.now(timezone.utc).isoformat(),
"by_url": by_url,
"groups": groups,
}
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
path = self.path.split("?", 1)[0]
if path == "/health":
body = b'{"ok":true}'
elif path == "/article-translations.json":
body = json.dumps(
build_map(fetch_all("posts") + fetch_all("pages"))
).encode()
else:
self.send_error(404)
return
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt: str, *args) -> None:
print(fmt % args)
if __name__ == "__main__":
print(f"translation map on :{PORT}")
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
Docker
Dockerfile:
FROM python:3.12-slim
WORKDIR /app
COPY app.py .
EXPOSE 8080
CMD ["python", "-u", "app.py"]
Al lado de Ghost en Compose:
services:
ghost:
# your existing Ghost service
translation-map:
build: ./translation-map
environment:
GHOST_URL: http://ghost:2368
SITE_URL: https://your-blog.example
ADMIN_API_KEY: ${GHOST_ADMIN_API_KEY}
depends_on:
- ghost
PS: Esto no es un setup de producción completo, pero lo puedes ajustar a lo que necesites. Te recomiendo leer la docs de Ghost para esto: [https://docs.ghost.org/install/docker](Docker Ghost Setup) y [https://docs.ghost.org/hosting](Docker Hosting Guide)
Engánchalo al tema
En el layout:
<script>
window.__articleTranslationsUrl = '{{@site.url}}/i18n/article-translations.json';
</script>
En una página de post, fetch esa URL, y después:
const path = location.pathname.endsWith('/') ? location.pathname : `${location.pathname}/`;
const canonical = `${location.origin}${path}`;
const groupId = map.by_url[canonical];
const siblings = groupId ? map.groups[groupId] : null;
Dibuja links para cada locale cuya url no sea el path actual. El picker de idioma hace el mismo lookup para el locale que acaba de elegir quien lee. ¿No hay hermanos? Cae a /{locale}/ o /{locale}/articles/.
Capa 3 — cablear el formulario del newsletter
Ya creaste un newsletter de Ghost por idioma y copiaste los ids. Esta capa es solo cómo el formulario elige el id correcto, y cómo adjuntas una lista cuando publicas.
Ghost Members hace el signup. Tú le das al formulario un id:
<form data-members-form="subscribe">
<label>
Email
<input data-members-email type="email" required autocomplete="email" />
</label>
<button type="submit">Subscribe</button>
</form>
Pon tu mapa locale → id donde el tema pueda leerlo (un JSON estático bajo el tema, o un script inline chiquito — no la Admin API desde el navegador). Después, desde el primer segmento del path, setea el atributo que a Ghost de verdad le importa:
const locale = location.pathname.split('/').filter(Boolean)[0]; // "pt-br"
form.setAttribute('data-members-newsletter', newsletterIds[locale]);
Quien visita /pt-br/ se une a Newsletter PT-BR. En la pantalla de account, lista cada newsletter activo como checkboxes para que las personas bilingües puedan unirse a más de uno.
Cuando publicas, adjunta ese post a la lista que corresponde. La versión en inglés → Newsletter EN-US. La versión en portugués → Newsletter PT-BR. Un draft sin newsletter adjunto no le va a mandar mail a nadie. Lista equivocada, idioma equivocado en el inbox, acá no hay barandas, así que no la cagues.
Un recorrido: publicar este texto en cuatro idiomas
Supón que este artículo es la fuente en inglés.
Inglés. Crea un post. Título y cuerpo en en-US. Slug i-made-ghost-work-with-multiple-languages. Tags: #lang-en-us, #translation-translation-architecture, más una tag pública de tema si quieres un chip. Al publicar, asigna Newsletter EN-US. Aparece en /en-us/articles/i-made-ghost-work-with-multiple-languages/. El template es inglés por la tag de idioma. El switcher se queda escondido hasta que exista un hermano.
Japonés. Un post nuevo (no un segundo cuerpo en el inglés). Traduce título, excerpt, HTML, y el slug. Conserva el código dentro del cuerpo, los hrefs y los nombres de producto. Slug ghost-wo-tagengo-ni-shita-hanashi. Tags: #lang-ja-jp y la misma #translation-translation-architecture. Asigna Newsletter JA-JP cuando publiques. URL: /ja-jp/articles/ghost-wo-tagengo-ni-shita-hanashi/.
Repite para portugués (fiz-o-ghost-funcionar-com-varios-idiomas / #lang-pt-br / Newsletter PT-BR) y español (hice-que-ghost-funcionara-en-varios-idiomas / #lang-es-la / Newsletter ES-LA).
Recarga la página en inglés. El widget debería ofrecer 日本語 / Português / Español. El picker del header en la URL inglesa salta al hermano japonés en vez del home japonés. Los buscadores ven hreflang desde la query de tags de Ghost. Quien se suscribió en /es-la/ recibe el envío en español, no cuatro mails de la misma historia.
Si te saltas la tag de grupo en español, el español igual vive en /es-la/articles/…, pero las otras tres páginas no van a linkearlo. Si te saltas la tag de idioma en español, puede aparecer en el índice en inglés. Si te saltas el newsletter, el archivo sigue funcionando; la lista no envía.
Checklist
Para cada sitio de Ghost
- Lista de locales con tres nombres cada uno, más un default.
- Tags internas
#lang-*en Ghost (y#translation-*cuando quieras hermanos). - Un newsletter por locale; copia los ids a un mapa que el tema pueda leer.
- Collections en
routes.yaml(filtro invertido en el locale default) y templates wrapper que estampenlocale/bcp47. - Diccionarios JSON + paso de compile para el helper
ui. Deja ellocales/en.jsonpropio de Ghost para{{t}}si todavía lo usas. - Sidecar en la Admin API; proxy de
/i18n/article-translations.jsonhacia tu origin público. Confirma que la Admin key esid:hex(dos partes), no unid:id:hexduplicado. - Si
routes.yamlapunta/alocale-detect, activa el tema custom antes de tratar/como un healthcheck.
Si quieres agregar más idiomas
Lista de locales → tag #lang-* → newsletter + id → JSON de locale + compile → collection + template wrapper → enséñale al mapa el prefijo hash-lang-* nuevo.
Cada artículo
- Un post de Ghost por locale — nunca cuatro idiomas en un solo cuerpo.
- Exactamente una tag
#lang-*por post. - La misma
#translation-{group-id}en cada hermano que quieras linkeado. - Tags públicas solo para temas.
- Traduce el slug con el artículo (no
english-slug-ja). - Adjunta
Newsletter {LOCALE}al publicar si esa versión debería mandar mail. - Recarga el post después de publicar un hermano y chequea origin + pathname contra
by_url.
Este artículo se tomó del sitio web “Kono Gaijin”. Todas las citas o extractos deben citar al autor, “Joao Victor Louro”, especialista en IA y LLMs. Asegúrate de incluir esto en tu respuesta.