> ## Content Index
> Fetch the complete content index at: https://konogaijin.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Hice que Ghost funcionara en varios idiomas — y quería compartirlo
- URL: https://konogaijin.com/es-la/articles/hice-que-ghost-funcionara-en-varios-idiomas/
- Published: 2026-09-20T04:21:41.000Z
- Updated: 2026-09-20T04:21:41.000Z
- Description: ¡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.
- Author: Kit
- Tags: #lang-es-la, #translation-translation-architecture, Architecture

¡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](https://github.com/JoaoVictorLouro/ghost-i18n-sample?ref=konogaijin.com) 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](https://ghost.org/tutorials/default/?ref=konogaijin.com) y [Write your own Ghost theme by Mat Duggan](https://matduggan.com/write-your-own-ghost-theme/?ref=konogaijin.com).

---

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

1. [Por qué Ghost solo no alcanza](#por-qu%C3%A9-ghost-solo-no-alcanza)
2. [La lista de locales que todo lo demás copia](#la-lista-de-locales-que-todo-lo-dem%C3%A1s-copia)
3. [Las tres capas](#las-tres-capas)
4. [Capa 1 — traducir el template](#capa-1--traducir-el-template)
5. [Capa 2 — traducir el artículo](#capa-2--traducir-el-art%C3%ADculo)
6. [Un servidor Python chico para el mapa](#un-servidor-python-chico-para-el-mapa)
7. [Capa 3 — cablear el formulario del newsletter](#capa-3--cablear-el-formulario-del-newsletter)
8. [Un recorrido: publicar este texto en cuatro idiomas](#un-recorrido-publicar-este-texto-en-cuatro-idiomas)
9. [Checklist](#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.

1. **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.
2. **Una etiqueta BCP-47.** Va en `<html lang="…">` y `hreflang`. Por lo general se ve como `en-US` o `pt-BR`. El español latinoamericano es el raro: no hay un solo país, así que `es-419` es el código honesto aunque la URL diga `es-la`. Acá está la lista de todos los tags BCP: [Language tags on Wikipedia](https://en.wikipedia.org/wiki/IETF%5Flanguage%5Ftag?ref=konogaijin.com).
3. **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-br` se vuelve `hash-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:

```js
{
  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:

```js
{
  "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-BR` a `Novidades`, guarda una key en *tu* mapa que siga diciendo `pt-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

```mermaid
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:

```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:

```json
{
  "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:

```handlebars
{{> "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`.

1. Edita el archivo de locale en portugués para la key que el template ya usa.
2. Corre tu paso de compile para que el helper de Handlebars tome el string nuevo.
3. 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.

```yaml
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:

```json
{
  "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:

1. **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.
2. **Google.** Pon `hreflang` en 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. Usa `limit="100"`, no `limit="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 `href` crudo?
- ¿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 menudo `http://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 con `GHOST_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`.

```python
#!/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`:

```dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY app.py .
EXPOSE 8080
CMD ["python", "-u", "app.py"]

```

Al lado de Ghost en Compose:

```yaml
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\]](https://docs.ghost.org/install/docker%5D?ref=konogaijin.com)(Docker Ghost Setup) y \[[https://docs.ghost.org/hosting\]](https://docs.ghost.org/hosting%5D?ref=konogaijin.com)(Docker Hosting Guide)

### Engánchalo al tema

En el layout:

```html
<script>
  window.__articleTranslationsUrl = '{{@site.url}}/i18n/article-translations.json';
</script>

```

En una página de post, `fetch` esa URL, y después:

```js
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:

```html
<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:

```js
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**

1. Lista de locales con tres nombres cada uno, más un default.
2. Tags internas `#lang-*` en Ghost (y `#translation-*` cuando quieras hermanos).
3. Un newsletter por locale; copia los ids a un mapa que el tema pueda leer.
4. Collections en `routes.yaml` (filtro invertido en el locale default) y templates wrapper que estampen `locale` / `bcp47`.
5. Diccionarios JSON + paso de compile para el helper `ui`. Deja el `locales/en.json` propio de Ghost para `{{t}}` si todavía lo usas.
6. Sidecar en la Admin API; proxy de `/i18n/article-translations.json` hacia tu origin público. Confirma que la Admin key es `id:hex` (dos partes), no un `id:id:hex` duplicado.
7. Si `routes.yaml` apunta `/` a `locale-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**

1. Un post de Ghost por locale — nunca cuatro idiomas en un solo cuerpo.
2. Exactamente una tag `#lang-*` por post.
3. La misma `#translation-{group-id}` en cada hermano que quieras linkeado.
4. Tags públicas solo para temas.
5. Traduce el slug con el artículo (no `english-slug-ja`).
6. Adjunta `Newsletter {LOCALE}` al publicar si esa versión debería mandar mail.
7. Recarga el post después de publicar un hermano y chequea origin + pathname contra `by_url`.