🏮 Lantern

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 → a key column 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

  1. Build-time pull (recommended). Run a pull step before your build/bundle so the shipped files are always current. You can .gitignore the generated locales/ or commit them — your call.
  2. 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.
  3. 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 IStringLocalizer JSON sources, or read directly
  • Rust: fluent/rust-i18n (you may prefer flat output — omit nested=1)

If your library wants flat keys ("home.title"), drop nested=1 from the URL.