stelegen 0.0.5

JSON-first, type-safe i18n codegen with pluggable per-language emitters
stelegen-0.0.5 is not a library.

Stele

JSON-first, type-safe i18n codegen with pluggable per-language emitters.

One JSON catalog in. Idiomatic, fully-typed accessor code out — for every language your product ships in. Think "protobuf for your copy."

A stele is an inscribed stone slab. The Rosetta Stone is a stele: one decree, carved in three scripts. Stele does the same thing for your app's strings — one source of truth, rendered into many languages of code.


The problem

You want translation files as plain JSON, because that's what's easy to hand to translators. But you don't want to write t("home.greeting") all over your code — it's a magic string the compiler can't check, and it's ugly.

Existing tools each solve half of this:

  • typesafe-i18n gives you nested typed accessors, but ships a runtime and is web-shaped.
  • Paraglide compiles to typed functions, but they're flat and web-shaped.
  • SwiftGen / R.swift are typed, but Swift-only.
  • TMS platforms (Tolgee, Crowdin, …) manage translations, then hand you stringly-typed native resources.

Nobody takes one JSON catalog and emits type-safe, idiomatic, zero-runtime accessors across many languages. That's Stele.

What you get

Author your copy as plain, translator-friendly JSON:

{
  "home": {
    "title": "Sidewalk",
    "greeting": "Hey {{name}}, there are dogs nearby",
    "nearby": {
      "$plural": {
        "one": "{{count}} dog within {{radius}} of you",
        "other": "{{count}} dogs within {{radius}} of you"
      }
    }
  }
}

Placeholders use the {{name}} double-brace convention (whitespace tolerant — {{ name }} works too). Literal single braces in copy are left untouched, so "set it to {0}" is safe.

Run stele generate, and call into it with full autocomplete and compile-time safety — in whichever language you're writing:

TypeScript

const copy = createCopy("en");
copy.home.title;                              // "Sidewalk"
copy.home.greeting({ name: "Brian" });        // typed; { nmae } is a compile error
copy.home.nearby({ count: 3, radius: "200m" });

Swift

let copy = Copy(.en)
copy.home.title                               // "Sidewalk"
copy.home.greeting(name: "Brian")             // idiomatic labeled args
copy.home.nearby(count: 3, radius: "200m")

Same catalog. Same structure. Each target gets the shape a native of that language expects — and in both, a typo'd parameter, a missing argument, a nonexistent key, or a wrong type is a compile error, not a runtime surprise.

Design principles

  • JSON-first. The JSON is the source of truth. Generated code is build output you can commit and review in PRs.
  • Zero-runtime output. Stele emits plain code — no proxy, no resolver, no runtime dependency. Tree-shakeable, bundler-friendly.
  • Hermes-safe by construction. Plural rules are resolved at generate time and baked into a static table. The emitted code never calls Intl.PluralRules — which matters because React Native's Hermes engine doesn't have it.
  • Pluggable emitters. A language-neutral, serializable IR is the contract. Adding a language is one emitter against that IR — the core never changes.

How it works

locales/*.json  ──▶  parse  ──▶  IR (serializable)  ──▶  emitter  ──▶  generated code
                                     │
                                     ├─▶ TypeScript
                                     ├─▶ Swift
                                     └─▶ … (your language here)

The IR is the seam. stele ir will print it as JSON, which means emitters can even be written in their own target language (the protoc-plugin model).

Install

The stele binary ships as a native package — no Rust toolchain needed. Add it and run it as a build step:

pnpm add -D @stelegen/cli        # or: npm i -D @stelegen/cli

The right prebuilt binary for your platform is pulled in automatically (macOS arm64/x64, Linux x64/arm64, Windows x64). Other ways in:

cargo install stelegen           # Rust users — installs the `stele` binary
cargo build --release            # from a clone — ./target/release/stele

Quickstart

Point stele at a config and generate, then commit the output and import it.

stele generate                   # reads ./stele.toml
stele ir --locales locales --canonical en   # inspect the IR

stele.toml:

canonical = "en"
locales   = "locales"

[[target]]
lang = "typescript"
out  = "src/copy.gen.ts"
# callable = true               # emit no-arg leaves as () => "..." thunks
# case = "camel"                # output identifier case (see below)

[[target]]
lang = "swift"
out  = "Sources/Copy.swift"

Locale layout

A locale can be one flat file, or split across files and folders — the path becomes the namespace, so you organize copy however your app is organized:

locales/
  en/
    nav.json            →  copy.nav.*
    walker/
      today.json        →  copy.walker.today.*
      schedule.json     →  copy.walker.schedule.*
  es/
    nav.json
    ...

locales/en.json (one file) and locales/en/ (a folder tree) both work, and may even be mixed; everything for a locale is deep-merged. A key defined twice across files is an error, never a silent clobber.

Key casing

Author keys in any case — walker_today, walkerToday, walker-today all parse the same. The case option on a target picks the output identifier case, applied uniformly to namespaces, leaves, and params:

case walker_today param first_name
camel (default) copy.walkerToday { firstName }
snake copy.walker_today { first_name }
pascal copy.WalkerToday { FirstName }
preserve verbatim verbatim

If two keys collapse to the same name under the chosen case (dog_count and dogCount), or a key isn't a valid identifier (2fa), generation fails loudly rather than emitting broken or silently-clobbered code.

Wire it into your build so it can't drift:

// package.json
"scripts": { "copy:gen": "stele generate" }

A CI check keeps generated output honest: stele generate && git diff --exit-code.

A worked example — input, config, and generated output for both languages — lives in examples/.

React / React Native

The core output stays zero-runtime and framework-agnostic. Add a react target to also emit reactive bindings — a tiny generated file that wraps the core in a Context, so changing the locale re-renders every useCopy() consumer. Works the same on web React and React Native (it's createElement + hooks, no JSX build).

[[target]]
lang = "typescript"
out  = "src/copy.gen.ts"

[[target]]
lang = "react"
out  = "src/copy.react.ts"
core = "./copy.gen"          # import path to the typescript target's output
import { CopyProvider, useCopy, useLocale } from "./copy.react";

// at the root:
<CopyProvider locale="en"><App /></CopyProvider>;

// in a component — re-renders when the locale changes:
const copy = useCopy();
const [locale, setLocale] = useLocale();
return <Text onPress={() => setLocale("es")}>{copy.home.greeting({ name })}</Text>;

Status

Early, but real. Both emitters are verified end-to-end: the generated code compiles, runs, and rejects bad calls at compile time.

  • JSON → serializable IR
  • TypeScript emitter (nested typed accessors, zero-runtime)
  • Swift emitter (idiomatic labeled args, nested structs)
  • ICU4X-backed plurals — authoritative CLDR rules baked into per-locale tables at generate time, validated against the oracle, emitted as pure lookups (correct one/few/many for Polish, Arabic, Russian, …; no runtime Intl.PluralRules, so it's Hermes-safe)
  • Distribution — native binary via npm (@stelegen/cli) and crates.io (stelegen), cross-compiled for macOS/Linux/Windows by CI on each tag
  • Multi-file / folder locales (path-as-namespace, deep-merged)
  • React / React Native bindings (react target) — reactive useCopy / useLocale / CopyProvider, locale change re-renders consumers
  • {{name}} placeholders (double-brace, whitespace tolerant, literal-{}-safe)
  • Any-input / chosen-output key casing (case option) with collision + invalid-id checks
  • More emitters (Kotlin, Go, Rust, Java)
  • $select (gender / arbitrary branching)
  • Validate $plural coverage against each locale's CLDR category set

License

MIT © 2026 Brian Corbin