Integrar Lantern (mantener sincronizados tus archivos de traducción)

En esta página

Lantern es la fuente de verdad de tus traducciones. Tu aplicación mantiene archivos de idioma locales (locales/en.json, locales/fr.json, …) sincronizados descargándolos de la API de lectura — normalmente como un paso de tu compilación o despliegue.

  • URL base: https://lantern.abyss-inn.ch
  • Autenticación: un token de API de la pestaña API del proyecto (uno por proyecto). Los tokens son de solo lectura de forma predeterminada; conceder permisos de escritura permite que un agente de IA o tu CI devuelvan claves y traducciones (véase Escritura inversa)
  • Endpoint: GET /api/v1/projects/<slug>/translations

El endpoint#

GET /api/v1/projects/<slug>/translations
Authorization: Bearer lk_…
Parámetro Valores Significado
format json (predeterminado), csv Formato de salida
locale un código de idioma (p. ej. en) Un solo idioma. Omítelo para todos
nested 1 JSON anidado ({"home":{"title":…}}) en lugar de plano ({"home.title":…})

Respuestas

  • ?locale=en&nested=1 → un solo idioma:
    { "home": { "title": "Welcome" } }
    
  • (sin locale) ?nested=1 → todos los idiomas, indexados por idioma (lo mejor para sincronizar archivos):
    { "en": { "home": { "title": "Welcome" } }, "fr": { "home": { "title": "Bienvenue" } } }
    
  • format=csv → una columna key más una columna por idioma.

Errores: 401 (token ausente/no válido), 403 (el token no corresponde al slug), 400 (idioma desconocido / formato no válido), 404 (proyecto no encontrado).

Para listar los idiomas del proyecto con sus nombres visibles (la llamada anterior solo expone los códigos), usa:

GET /api/v1/projects/<slug>/languages
Authorization: Bearer lk_…
→ 200 [ { "locale": "en", "name": "English", "isDefault": true }, … ]

Seguridad del token#

  • El token es de solo lectura y está limitado a un proyecto. Guárdalo como variable de entorno / secreto de CI (por ejemplo LANTERN_TOKEN) — nunca lo subas al repositorio.
  • Para rotarlo: revócalo en la pestaña API y crea uno nuevo.

Estrategias de sincronización#

  1. Descarga en tiempo de compilación (recomendado). Ejecuta un paso de descarga antes de compilar para que los archivos publicados estén siempre al día. Puedes añadir el locales/ generado a .gitignore o versionarlo — tú decides.
  2. Descarga en ejecución + caché. Los servidores de larga duración pueden descargar al arrancar y refrescar cada cierto intervalo. Cachea en memoria y recurre a la última copia válida si hay un error.
  3. Programada. Para aplicaciones en ejecución que no se redespliegan, programa la descarga con cron y recarga.

Código#

Cada ejemplo de abajo descarga todos los idiomas en una sola petición (?nested=1) y escribe locales/<locale>.json. Para descargar un único idioma, añade ?locale=en y escribe un solo archivo. Define LANTERN_TOKEN en tu entorno y sustituye my-project por tu slug.

Shell (curl + jq) — universal#

#!/usr/bin/env bash
set -euo pipefail
: "${LANTERN_TOKEN:?set LANTERN_TOKEN}"
BASE=https://lantern.abyss-inn.ch ; SLUG=my-project ; OUT=locales
mkdir -p "$OUT"
curl -fsS -H "Authorization: Bearer $LANTERN_TOKEN" \
  "$BASE/api/v1/projects/$SLUG/translations?nested=1" \
| jq -c 'to_entries[]' | while read -r e; do
    loc=$(jq -r '.key' <<<"$e")
    jq '.value' <<<"$e" > "$OUT/$loc.json"
    echo "wrote $OUT/$loc.json"
  done

JavaScript (Node)#

Se incluye una CLI lista para usar — para proyectos JS/TS es lo más sencillo:

LANTERN_TOKEN=lk_… node cli/lantern-pull.mjs \
  --url https://lantern.abyss-inn.ch --project my-project --out locales

Equivalente en línea:

import { mkdir, writeFile } from "node:fs/promises";

const BASE = "https://lantern.abyss-inn.ch", SLUG = "my-project";
const res = await fetch(`${BASE}/api/v1/projects/${SLUG}/translations?nested=1`, {
  headers: { Authorization: `Bearer ${process.env.LANTERN_TOKEN}` },
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const all = await res.json();

await mkdir("locales", { recursive: true });
for (const [loc, messages] of Object.entries(all)) {
  await writeFile(`locales/${loc}.json`, JSON.stringify(messages, null, 2) + "\n");
}

TypeScript#

import { mkdir, writeFile } from "node:fs/promises";

type Messages = Record<string, unknown>;
type AllLocales = Record<string, Messages>;

const BASE = "https://lantern.abyss-inn.ch", SLUG = "my-project";
const res = await fetch(`${BASE}/api/v1/projects/${SLUG}/translations?nested=1`, {
  headers: { Authorization: `Bearer ${process.env.LANTERN_TOKEN}` },
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const all = (await res.json()) as AllLocales;

await mkdir("locales", { recursive: true });
for (const [loc, messages] of Object.entries(all)) {
  await writeFile(`locales/${loc}.json`, JSON.stringify(messages, null, 2) + "\n");
}

Python (biblioteca estándar, sin dependencias)#

import json, os, pathlib, urllib.request

BASE, SLUG = "https://lantern.abyss-inn.ch", "my-project"
req = urllib.request.Request(
    f"{BASE}/api/v1/projects/{SLUG}/translations?nested=1",
    headers={"Authorization": f"Bearer {os.environ['LANTERN_TOKEN']}"},
)
all_locales = json.load(urllib.request.urlopen(req))

out = pathlib.Path("locales"); out.mkdir(exist_ok=True)
for loc, messages in all_locales.items():
    (out / f"{loc}.json").write_text(
        json.dumps(messages, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
    )
    print("wrote", loc)

C##

using System.Net.Http.Headers;
using System.Text.Json;

var baseUrl = "https://lantern.abyss-inn.ch";
var slug = "my-project";
var token = Environment.GetEnvironmentVariable("LANTERN_TOKEN")!;

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);

var json = await http.GetStringAsync(
    $"{baseUrl}/api/v1/projects/{slug}/translations?nested=1");

using var doc = JsonDocument.Parse(json);
Directory.CreateDirectory("locales");
var opts = new JsonSerializerOptions { WriteIndented = true };
foreach (var locale in doc.RootElement.EnumerateObject())
{
    var text = JsonSerializer.Serialize(locale.Value, opts);
    await File.WriteAllTextAsync($"locales/{locale.Name}.json", text);
    Console.WriteLine($"wrote {locale.Name}");
}

Rust#

# Cargo.toml
[dependencies]
ureq = { version = "2", features = ["json"] }
serde_json = "1"
use std::{env, fs};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let base = "https://lantern.abyss-inn.ch";
    let slug = "my-project";
    let token = env::var("LANTERN_TOKEN")?;
    let url = format!("{base}/api/v1/projects/{slug}/translations?nested=1");

    let all: serde_json::Value = ureq::get(&url)
        .set("Authorization", &format!("Bearer {token}"))
        .call()?
        .into_json()?;

    fs::create_dir_all("locales")?;
    for (loc, messages) in all.as_object().unwrap() {
        fs::write(
            format!("locales/{loc}.json"),
            serde_json::to_string_pretty(messages)?,
        )?;
        println!("wrote {loc}");
    }
    Ok(())
}

Go#

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	base, slug := "https://lantern.abyss-inn.ch", "my-project"
	req, _ := http.NewRequest("GET", base+"/api/v1/projects/"+slug+"/translations?nested=1", nil)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("LANTERN_TOKEN"))

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var all map[string]json.RawMessage
	if err := json.NewDecoder(res.Body).Decode(&all); err != nil {
		panic(err)
	}

	os.MkdirAll("locales", 0o755)
	for loc, msgs := range all {
		var pretty any
		json.Unmarshal(msgs, &pretty)
		out, _ := json.MarshalIndent(pretty, "", "  ")
		os.WriteFile("locales/"+loc+".json", out, 0o644)
		fmt.Println("wrote", loc)
	}
}

PHP#

<?php
$base = "https://lantern.abyss-inn.ch";
$slug = "my-project";
$token = getenv("LANTERN_TOKEN");

$ctx = stream_context_create(["http" => ["header" => "Authorization: Bearer $token"]]);
$json = file_get_contents("$base/api/v1/projects/$slug/translations?nested=1", false, $ctx);
$all = json_decode($json, true);

@mkdir("locales");
foreach ($all as $loc => $messages) {
    file_put_contents(
        "locales/$loc.json",
        json_encode($messages, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
    );
    echo "wrote $loc\n";
}

Escritura inversa — enviar claves y traducciones#

La sincronización de lectura es unidireccional (Lantern → tus archivos). Un token con permisos de escritura también puede enviar en el otro sentido — ideal para un agente de IA que redacta traducciones, o para un paso de CI que registra las claves nuevas que encuentra en el código. Concede los ámbitos al crear el token en la pestaña API:

Ámbito Permite
key:create Crear claves de traducción
key:edit Renombrar una clave / cambiar su espacio de nombres o su descripción
translation:edit Establecer/sobrescribir valores de traducción
language:manage Crear automáticamente un idioma de destino que falte al enviar

Endpoints#

POST /api/v1/projects/<slug>/keys
Authorization: Bearer lk_…            (needs key:create)
{ "key": "home.title", "description": "Landing headline" }
→ 201 { "id": "…", "key": "home.title", "created": true }   (409 if it exists)
PATCH /api/v1/projects/<slug>/keys
Authorization: Bearer lk_…            (needs key:edit)
{ "key": "home.title", "namespace": "home", "name": "headline" }
→ 200 { "id": "…", "key": "home.headline", "updated": true }   (409 if it collides)

Identifica la clave por su ruta con puntos key actual o por keyId; los demás campos (name, namespace, description) son los nuevos valores, y los que omitas conservan su valor actual. La actualización se hace por id, así que las traducciones, los estados y los comentarios de la clave sobreviven a un cambio de nombre — no hay que borrar y recrear.

PUT /api/v1/projects/<slug>/translations
Authorization: Bearer lk_…            (needs translation:edit)
{ "locale": "fr", "createMissingKeys": true,
  "entries": [ { "key": "home.title", "value": "Bienvenue" } ] }
→ 200 { "updated": 1, "created": 0, "skipped": 0 }

createMissingKeys requiere además key:create; enviar a un idioma que aún no existe requiere language:manage (de lo contrario la petición se rechaza con 400 Unknown locale). Las escrituras hechas con un token aparecen en el registro de actividad del proyecto, atribuidas al nombre del token.

CLI de envío#

Se incluye cli/lantern-push.mjs (sin dependencias, simétrica a la CLI de descarga):

# Register keys (a JSON array of dotted paths, or [{ "key", "namespace", "description" }])
LANTERN_TOKEN=lk_… node cli/lantern-push.mjs \
  --url https://lantern.abyss-inn.ch --project my-project --keys keys.json

# Push one locale from a flat or nested JSON file (creating any missing keys)
LANTERN_TOKEN=lk_… node cli/lantern-push.mjs \
  --url https://lantern.abyss-inn.ch --project my-project \
  --locale fr --in fr.json --create-keys

Errores: 401 (token no válido), 403 (falta el ámbito, o el token no corresponde al slug), 400 (cuerpo no válido / idioma desconocido), 409 (la clave ya existe).


Usar Lantern desde un agente de IA (MCP)#

Lantern aloja un servidor MCP para que un agente de IA (Claude Code / Claude Desktop, etc.) pueda leer y escribir las traducciones de un proyecto de forma conversacional — sin código pegamento a medida y sin nada que instalar. Los ámbitos del token deciden lo que puede hacer el agente (un token de solo lectura puede leer, pero las herramientas de escritura devuelven 403).

Crea un token lk_ en la pestaña API del proyecto y apunta tu cliente MCP al endpoint:

{
  "mcpServers": {
    "lantern": {
      "url": "https://lantern.abyss-inn.ch/api/v1/mcp",
      "headers": { "Authorization": "Bearer lk_…" }
    }
  }
}

El token ya identifica el proyecto, así que no hay nada más que configurar.

Herramientas expuestas al agente:

Herramienta Qué hace Ámbito necesario
list_projects Los proyectos a los que puede acceder este token
list_languages Los idiomas de un proyecto (código + nombre visible + predeterminado)
get_translations Leer traducciones (locale?, nested?, format?)
create_key Crear una clave key:create
set_translations Insertar/actualizar valores de un idioma (createMissingKeys?) translation:edit

createMissingKeys requiere además key:create; crear automáticamente un idioma nuevo requiere language:manage.

Un token lk_ está limitado a un proyecto, así que las herramientas actúan sobre él automáticamente. Una conexión OAuth a nivel de usuario (más abajo) puede llegar a todos tus proyectos, por lo que sus herramientas reciben un slug project — llama primero a list_projects y luego pasa project a las demás. Los permisos se vuelven a comprobar por proyecto en cada llamada, así que el agente nunca podrá hacer en un proyecto más de lo que puedes hacer tú.

Conectar con OAuth (iniciar sesión en vez de pegar un token)#

Si tu cliente MCP admite OAuth (los conectores de Claude, muchos agentes de IDE), puedes conectarte iniciando sesión en lugar de crear y pegar un token lk_. Apunta el cliente a la misma URL sin cabecera Authorization:

{
  "mcpServers": {
    "lantern": { "url": "https://lantern.abyss-inn.ch/api/v1/mcp" }
  }
}

El cliente descubre automáticamente el servidor de autorización de Lantern, abre un navegador para iniciar sesión y muestra una pantalla de consentimiento que confirma que el agente actuará como tú, en todos tus proyectos. Una sola autorización cubre todos los proyectos que puedes ver (el agente elige uno por llamada mediante el argumento project); solo puedes conceder permisos que tú mismo tengas, y cada uno se vuelve a comprobar por proyecto en el momento de la llamada. El acceso está ligado a tu cuenta — revócalo cuando quieras iniciando sesión en otro sitio o contactando con quien administra el espacio de trabajo. Lantern implementa el estándar OAuth 2.1 (PKCE, registro dinámico de clientes, tokens de refresco rotatorios), así que funciona cualquier cliente MCP que cumpla la especificación.

El método del token lk_ anterior sigue funcionando igual: es la opción adecuada para CLI, scripts y CI, donde no hay un navegador que complete un inicio de sesión.

En CI (GitHub Actions)#

Añade LANTERN_TOKEN como secreto del repositorio y descarga antes de compilar:

- name: Pull translations from Lantern
  env:
    LANTERN_TOKEN: ${{ secrets.LANTERN_TOKEN }}
  run: |
    node cli/lantern-pull.mjs \
      --url https://lantern.abyss-inn.ch --project my-project --out locales

(O llama al script de descarga de tu lenguaje, de los de arriba.) La compilación empaquetará entonces los archivos actualizados.

Conectar los archivos a una biblioteca i18n#

El JSON anidado coincide con lo que esperan la mayoría de las bibliotecas i18n, por ejemplo:

  • JS/TS: i18next (resources), react-intl, vue-i18n, next-intl
  • Python: carga el JSON y busca por clave con puntos (o salida plana: quita nested=1)
  • C#: enlázalo a fuentes JSON de IStringLocalizer, o léelo directamente
  • Rust: fluent/rust-i18n (probablemente prefieras la salida plana — omite nested=1)

Si tu biblioteca quiere claves planas ("home.title"), quita nested=1 de la URL.

Unity (Unity Localization)#

Para Unity, no conectes el JSON en bruto: usa el paquete de Localization oficial de Unity como runtime (Locales, String Tables, LocalizedString, compatibilidad con TMP) y deja que Lantern lo alimente.

El paquete lantern-unity es un puente solo para el editor: un botón Window ▸ Lantern ▸ Pull Translations descarga un proyecto (mediante la salida format=csv de la API de lectura) directamente a una String Table Collection con un clic — sin archivos manuales. Instálalo desde el Package Manager con Add package from git URL…:

https://github.com/Rabzizz/lantern-unity.git#v0.1.0

com.unity.localization es una dependencia, así que se instala automáticamente. Usa un token lk_ de solo lectura; se guarda por máquina en EditorPrefs y nunca se incluye en tu build. Por ahora es de solo lectura (Lantern → Unity).

¿Sin paquete, solo CSV? Unity Localization puede importar el CSV de Lantern directamente: guarda …/translations?format=csv en un archivo, añade la extensión CSV a tu String Table Collection, apúntala al archivo y pulsa Import. El paquete simplemente automatiza ese recorrido.