Integrare Lantern (tenere sincronizzati i file di traduzione)

In questa pagina

Lantern è la fonte di verità per le tue traduzioni. La tua applicazione mantiene file di lingua locali (locales/en.json, locales/fr.json, …) sincronizzati scaricandoli dall'API di lettura — di solito come passaggio della build o del deploy.

  • URL di base: https://lantern.abyss-inn.ch
  • Autenticazione: un token API dalla scheda API del progetto (uno per progetto). I token sono in sola lettura per impostazione predefinita; concedere permessi di scrittura consente a un agente IA o alla CI di rimandare indietro chiavi e traduzioni (vedi Scrittura inversa)
  • Endpoint: GET /api/v1/projects/<slug>/translations

L'endpoint#

GET /api/v1/projects/<slug>/translations
Authorization: Bearer lk_…
Parametro Valori Significato
format json (predefinito), csv Formato di output
locale un codice lingua (es. en) Una sola lingua. Omettilo per tutte
nested 1 JSON annidato ({"home":{"title":…}}) invece che piatto ({"home.title":…})

Risposte

  • ?locale=en&nested=1 → una sola lingua:
    { "home": { "title": "Welcome" } }
    
  • (senza locale) ?nested=1 → tutte le lingue, indicizzate per lingua (ideale per sincronizzare i file):
    { "en": { "home": { "title": "Welcome" } }, "fr": { "home": { "title": "Bienvenue" } } }
    
  • format=csv → una colonna key più una colonna per lingua.

Errori: 401 (token mancante/non valido), 403 (il token non corrisponde allo slug), 400 (lingua sconosciuta / formato non valido), 404 (progetto non trovato).

Per elencare le lingue del progetto con i nomi visualizzati (la chiamata sopra espone solo i codici lingua), usa:

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

Sicurezza dei token#

  • Il token è in sola lettura ed è limitato a un solo progetto. Conservalo come variabile d'ambiente / segreto di CI (per esempio LANTERN_TOKEN) — non committarlo mai.
  • Per ruotarlo: revocalo nella scheda API e creane uno nuovo.

Strategie di sincronizzazione#

  1. Download in fase di build (consigliato). Esegui un passaggio di pull prima della build così che i file distribuiti siano sempre aggiornati. Puoi mettere la cartella locales/ generata in .gitignore oppure committarla — decidi tu.
  2. Download a runtime + cache. I server a lunga esecuzione possono scaricare all'avvio e aggiornare a intervalli. Tieni una cache in memoria e in caso di errore ripiega sull'ultima copia valida.
  3. Pianificata. Per applicazioni a runtime che non vengono ridistribuite, pianifica il pull con cron e ricarica.

Codice#

Ogni esempio qui sotto scarica tutte le lingue in un'unica richiesta (?nested=1) e scrive locales/<locale>.json. Per scaricare invece una sola lingua, aggiungi ?locale=en e scrivi un solo file. Imposta LANTERN_TOKEN nel tuo ambiente e sostituisci my-project con il tuo slug.

Shell (curl + jq) — universale#

#!/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)#

È inclusa una CLI pronta all'uso — per i progetti JS/TS è la via più semplice:

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

Equivalente inline:

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 (libreria standard, senza dipendenze)#

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";
}

Scrittura inversa — inviare chiavi e traduzioni#

La sincronizzazione in lettura è a senso unico (Lantern → i tuoi file). Un token con permessi di scrittura può inviare anche nella direzione opposta — ideale per un agente IA che redige traduzioni, o per un passaggio di CI che registra le nuove chiavi trovate nel codice sorgente. Concedi gli ambiti quando crei il token nella scheda API:

Ambito Consente
key:create Creare chiavi di traduzione
key:edit Rinominare una chiave / cambiarne il namespace o la descrizione
translation:edit Impostare/sovrascrivere i valori delle traduzioni
language:manage Creare automaticamente una lingua di destinazione mancante durante l'invio

Endpoint#

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 chiave tramite il suo percorso puntato key attuale oppure tramite keyId; gli altri campi (name, namespace, description) sono i nuovi valori, e quelli che ometti mantengono il valore corrente. L'aggiornamento avviene per id, quindi traduzioni, stati e commenti della chiave sopravvivono a una rinomina — niente elimina-e-ricrea.

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 richiede anche key:create; inviare verso una lingua non ancora esistente richiede language:manage (altrimenti la richiesta viene rifiutata con 400 Unknown locale). Le scritture da token compaiono nel feed delle attività del progetto, attribuite al nome del token.

CLI di invio#

È incluso cli/lantern-push.mjs (senza dipendenze, speculare alla CLI di download):

# 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

Errori: 401 (token non valido), 403 (ambito mancante, o il token non corrisponde allo slug), 400 (corpo non valido / lingua sconosciuta), 409 (la chiave esiste già).


Usare Lantern da un agente IA (MCP)#

Lantern ospita un server MCP perché un agente IA (Claude Code / Claude Desktop, ecc.) possa leggere e scrivere le traduzioni di un progetto in modo conversazionale — senza collante su misura, niente da installare. Sono gli ambiti del token a decidere cosa può fare l'agente (un token in sola lettura può leggere, ma gli strumenti di scrittura restituiscono 403).

Crea un token lk_ nella scheda API del progetto, poi punta il tuo client MCP sull'endpoint:

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

Il token identifica già il progetto, quindi non c'è altro da configurare.

Strumenti esposti all'agente:

Strumento Cosa fa Ambito richiesto
list_projects I progetti a cui questo token può accedere
list_languages Le lingue di un progetto (codice + nome visualizzato + predefinita)
get_translations Leggere le traduzioni (locale?, nested?, format?)
create_key Creare una chiave key:create
set_translations Inserire/aggiornare i valori di una lingua (createMissingKeys?) translation:edit

createMissingKeys richiede anche key:create; creare automaticamente una nuova lingua richiede language:manage.

Un token lk_ è limitato a un solo progetto, quindi gli strumenti vi agiscono automaticamente. Una connessione OAuth a livello di utente (sotto) può raggiungere tutti i tuoi progetti, perciò i suoi strumenti accettano uno slug project — chiama prima list_projects, poi passa project agli altri. I permessi vengono riverificati progetto per progetto a ogni chiamata, quindi l'agente non potrà mai fare su un progetto più di quanto puoi farci tu.

Connettersi con OAuth (accedere invece di incollare un token)#

Se il tuo client MCP supporta OAuth (i connettori di Claude, molti agenti da IDE), puoi connetterti accedendo invece di creare e incollare un token lk_. Punta il client sullo stesso URL senza intestazione Authorization:

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

Il client individua automaticamente il server di autorizzazione di Lantern, apre un browser per l'accesso e mostra una schermata di consenso che conferma che l'agente agirà come te, su tutti i tuoi progetti. Una sola autorizzazione copre ogni progetto che puoi vedere (l'agente ne sceglie uno per chiamata tramite l'argomento project); puoi concedere solo permessi che possiedi tu stesso, e ognuno viene riverificato per progetto al momento della chiamata. L'accesso è legato al tuo account — revocalo quando vuoi accedendo altrove o contattando chi possiede lo spazio di lavoro. Lantern implementa lo standard OAuth 2.1 (PKCE, registrazione dinamica dei client, refresh token a rotazione), quindi funziona qualsiasi client MCP conforme alle specifiche.

Il metodo con token lk_ descritto sopra continua a funzionare invariato: è la scelta giusta per CLI, script e CI, dove non c'è un browser per completare un accesso.

In CI (GitHub Actions)#

Aggiungi LANTERN_TOKEN come segreto del repository, poi scarica prima della build:

- 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

(Oppure richiama lo script di download del tuo linguaggio, qui sopra.) La build includerà quindi i file aggiornati.

Collegare i file a una libreria i18n#

Il JSON annidato corrisponde a ciò che si aspetta la maggior parte delle librerie i18n, per esempio:

  • JS/TS: i18next (resources), react-intl, vue-i18n, next-intl
  • Python: carica il JSON e cerca per chiave puntata (oppure output piatto: togli nested=1)
  • C#: collegalo a sorgenti JSON per IStringLocalizer, oppure leggilo direttamente
  • Rust: fluent/rust-i18n (probabilmente preferirai l'output piatto — ometti nested=1)

Se la tua libreria vuole chiavi piatte ("home.title"), togli nested=1 dall'URL.

Unity (Unity Localization)#

Per Unity non collegare il JSON grezzo: usa il pacchetto di Localization ufficiale di Unity come runtime (Locale, String Table, LocalizedString, supporto TMP) e lascia che sia Lantern ad alimentarlo.

Il pacchetto lantern-unity è un ponte solo per l'editor: un pulsante Window ▸ Lantern ▸ Pull Translations importa un progetto (tramite l'output format=csv dell'API di lettura) direttamente in una String Table Collection, con un clic — senza file da gestire a mano. Installalo dal Package Manager con Add package from git URL…:

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

com.unity.localization è una dipendenza, quindi si installa automaticamente. Usa un token lk_ in sola lettura; viene salvato per macchina in EditorPrefs e non finisce mai nella tua build. Per ora è in sola lettura (Lantern → Unity).

Niente pacchetto, solo CSV? Unity Localization può importare direttamente il CSV di Lantern: salva …/translations?format=csv in un file, aggiungi l'estensione CSV alla tua String Table Collection, puntala al file e premi Import. Il pacchetto non fa che automatizzare questo giro.