Integrating Lantern (keeping your translation files in sync)
Lantern is the source of truth for your translations. Your app keeps local locale
files (locales/en.json, locales/fr.json, …) in sync by pulling them from the
read API — typically as a step in your build or deploy.
- Base URL:
https://lantern.abyss-inn.ch - Auth: an API token from the project's API tab (read-only, per project)
- Endpoint:
GET /api/v1/projects/<slug>/translations
The endpoint
GET /api/v1/projects/<slug>/translations
Authorization: Bearer lk_…
| Query param | Values | Meaning |
|---|---|---|
format |
json (default), csv |
Output format |
locale |
a locale code (e.g. en) |
One language. Omit for all languages |
nested |
1 |
Nested JSON ({"home":{"title":…}}) instead of flat ({"home.title":…}) |
Responses
?locale=en&nested=1→ one language:{ "home": { "title": "Welcome" } }- (no
locale)?nested=1→ all languages, keyed by locale (best for syncing files):{ "en": { "home": { "title": "Welcome" } }, "fr": { "home": { "title": "Bienvenue" } } } format=csv→ akeycolumn plus one column per locale.
Errors: 401 (missing/invalid token), 403 (token doesn't match the slug),
400 (unknown locale / bad format), 404 (project not found).
Token security
- The token is read-only and scoped to one project. Store it as an environment
variable / CI secret (e.g.
LANTERN_TOKEN) — never commit it. - To rotate: revoke it on the API tab and create a new one.
Sync strategies
- Build-time pull (recommended). Run a pull step before your build/bundle so the
shipped files are always current. You can
.gitignorethe generatedlocales/or commit them — your call. - Runtime fetch + cache. Long-running servers can fetch on boot and refresh on an interval. Cache in memory; fall back to the last good copy on error.
- Scheduled. For runtime apps that don't redeploy, cron the pull and reload.
Code
Every example below fetches all locales in one request (?nested=1) and writes
locales/<locale>.json. To pull a single language instead, add ?locale=en and write
one file. Set LANTERN_TOKEN in your environment and replace my-project with your 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)
The repo ships a ready CLI — for JS/TS projects this is the simplest:
LANTERN_TOKEN=lk_… node cli/lantern-pull.mjs \
--url https://lantern.abyss-inn.ch --project my-project --out locales
Inline equivalent:
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 (standard library, no dependencies)
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";
}
In CI (GitHub Actions)
Add LANTERN_TOKEN as a repository secret, then pull before building:
- 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
(Or call your language's pull script above.) The build then bundles the fresh files.
Plugging the files into an i18n library
The nested JSON matches what most i18n libraries expect, e.g.:
- JS/TS:
i18next(resources),react-intl,vue-i18n,next-intl - Python: load the JSON and look up by dotted key (or flat output: drop
nested=1) - C#: bind to
IStringLocalizerJSON sources, or read directly - Rust:
fluent/rust-i18n(you may prefer flat output — omitnested=1)
If your library wants flat keys ("home.title"), drop nested=1 from the URL.