stelegen 0.0.12

JSON-first, type-safe i18n codegen with pluggable per-language emitters
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
# 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:

```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**
```ts
const stele = createStele("en");
stele.home.title;                              // "Sidewalk"
stele.home.greeting({ name: "Brian" });        // typed; { nmae } is a compile error
stele.home.nearby({ count: 3, radius: "200m" });
```

**Swift**
```swift
let stele = Stele(.en)
stele.home.title                               // "Sidewalk"
stele.home.greeting(name: "Brian")             // idiomatic labeled args
stele.home.nearby(count: 3, radius: "200m")
```

The accessor is named `stele` by default — `import { stele }` and call `stele.home.greeting(...)`, the way you `import { z } from "zod"` and write `z.string()`. One config line (`binding`) renames it to whatever you like (`copy`, `t`, …), uniformly across every target.

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:

```bash
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:

```bash
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.

```bash
stele generate                   # reads ./stele.toml
stele check                      # validate the catalog across locales (CI gate)
stele ir --locales locales --canonical en   # inspect the IR
```

`stele.toml`:

```toml
canonical = "en"
locales   = "locales"

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

[[target]]
lang = "swift"
out  = "Sources/Stele.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            →  stele.nav.*
    walker/
      today.json        →  stele.walker.today.*
      schedule.json     →  stele.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) | `stele.walkerToday` | `{ firstName }` |
| `snake` | `stele.walker_today` | `{ first_name }` |
| `pascal` | `stele.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.

### API binding name

The generated API is branded `stele` by default — the accessor type is `Stele`,
the factory is `createStele`, and the `react` target exports `useStele` /
`SteleProvider`. The `binding` option on a target renames that whole surface with
one word, applied uniformly across every language:

| `binding` | TypeScript / Swift | React |
|---|---|---|
| `stele` (default) | `createStele` / `Stele` | `useStele`, `SteleProvider` |
| `copy` | `createCopy` / `Copy` | `useCopy`, `CopyProvider` |
| `t` | `createT` / `T` | `useT`, `TProvider` |

It's purely cosmetic — the emitted code is identical apart from those names — so a
team can pick the word that reads best at their call sites without any lock-in.

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

```jsonc
// 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/`](examples/).

### The locale store

The core factory (`createStele(locale)`) is pure — you pass the locale in. For an
app you usually want *one* active locale that any code can read or change, that
persists across launches, and that re-renders the UI when it flips. That's the
`store` target: a tiny framework-agnostic module that owns the active locale and
is the single source of truth.

```toml
[[target]]
lang = "typescript"
out  = "src/stele.gen.ts"

[[target]]
lang = "store"
out  = "src/stele.store.ts"
core = "./stele.gen"          # import path to the typescript target's output
```

```ts
import {
  getStele, getLocale, setLocale, subscribeLocale,  // read / write / observe
  followDevice, isFollowingDevice, syncDevice,        // device / "system" mode
  resolveLocale, initLocale,                          // startup helpers
} from "./stele.store";

getStele().home.greeting({ name });   // accessor bound to the active locale
getLocale();                          // "en"
setLocale("es");                      // pin Spanish — stops following the device, persists
followDevice();                       // back to following the device locale, persists "system"
```

The store tracks a **preference**, not just a locale: either `"system"` (follow
the device) or a pinned `Locale`. That's the difference between "remember my
choice" and "follow my phone" — keeping them distinct is what avoids the classic
bug where a once-saved locale shadows the device forever.

- **`resolveLocale(tags)`** maps arbitrary BCP-47 device tags to a supported
  `Locale` (`["es-MX","en-US"] → "es"`), falling back to the canonical locale.
- **`initLocale({ storage, deviceLocales })`** is the one-call startup: restore the
  saved preference (or default to `"system"`), wire the device-locale source, and
  apply it. Persistence is a pluggable adapter (`LocaleStorage`) storing
  `"system" | Locale`*you* hand it AsyncStorage / localStorage, so the store
  pulls in no platform dependency.
- **`syncDevice()`** re-reads the device locale *only while in system mode* — wire
  it to an `AppState` "active" listener to track the OS language changing live.

```ts
// once, before your first render (e.g. behind a splash screen):
await initLocale({
  storage: {                                          // ~3 lines you provide
    load: () => AsyncStorage.getItem("locale") as Promise<LocalePref | null>,
    save: (p) => AsyncStorage.setItem("locale", p),
  },
  deviceLocales: () => Localization.locales,          // expo-localization, navigator.languages, …
});

// follow the device with no persistence at all? just:
await initLocale({ deviceLocales: () => Localization.locales });
```

### React / React Native

Add a `react` target to bind the store to React via `useSyncExternalStore` —
hooks only, **no Provider to mount**. `setLocale` from anywhere (a component or
not) re-renders every `useStele()` consumer. Works the same on web React and
React Native (no JSX build step).

```toml
[[target]]
lang = "react"
out  = "src/stele.react.ts"
store = "./stele.store"       # import path to the store target's output
```

```tsx
import { useStele, useLocale, useFollowingDevice } from "./stele.react";

// in a component — re-renders when the locale changes:
const stele = useStele();
const [locale, setLocale] = useLocale();
const onSystem = useFollowingDevice();   // for a "System" radio in settings
return <Text onPress={() => setLocale("es")}>{stele.home.greeting({ name })}</Text>;
```

### As a node package (no files in your repo)

Instead of loose `.ts` files, Stele can emit a **compiled package** — `.js` +
`.d.ts` + `package.json` — via a `[package]` block. Point `out` straight at
`node_modules/<name>/` and Stele writes a real, import-by-name package there;
nothing lands in your source tree. It's the "codegen as a dependency" model, à la
Prisma Client.

```toml
[package]
name  = "@myapp/copy"
out   = "node_modules/@myapp/copy"
store = true
react = true
```

```jsonc
// package.json — regenerate on install (see the caveat below)
"scripts": { "postinstall": "stele generate", "copy:gen": "stele generate" }
```

```ts
import { createStele }          from "@myapp/copy";
import { useStele, useLocale }  from "@myapp/copy/react";
import { getStele, initLocale } from "@myapp/copy/store";
```

Stele emits the `.js` and `.d.ts` **directly** — no `tsc` in the loop, it stays
one binary. The `.js` is **CommonJS**, so React Native / Metro / Jest consume it
from `node_modules` with **zero config** (ESM there would need
`transformIgnorePatterns` allowlisting); ESM and bundler consumers still `import`
it via interop. `tsc` trusts the `.d.ts`, and the package resolves by name through
its `exports` map (`.`, `./store`, `./react`). A generated example lives in
[`examples/out/pkg/`](examples/out/pkg/).

> CommonJS — not dual CJS+ESM — is deliberate: the locale store is a stateful
> singleton, and a dual package risks loading it twice (the dual-package hazard),
> forking the active locale. One format, one store.

> **`node_modules` is the package manager's turf.** `npm` / `pnpm` / `yarn install`
> prune directories they didn't create, so you must regenerate on `postinstall`.
> Under pnpm's strict store this is the same pattern — and the same caveats — as
> Prisma Client; Metro's resolver tends to tolerate it well for React Native. If
> your setup fights it, point `out` at a gitignored folder and add one resolver
> alias instead — same artifact, different home.

### Validate the catalog — `stele check`

Generation makes the *current* locale type-safe; `stele check` keeps the whole
catalog honest as you add locales. It compares every locale against the canonical
one and exits non-zero on problems — wire it into CI:

```bash
stele check            # errors fail; warnings don't
stele check --strict   # warnings fail too
```

It catches:

- **missing translations** — a canonical key absent in another locale *(error)*
- **placeholder drift**`{{nombre}}` where the canonical says `{{name}}` would
  be `undefined` at runtime *(error)*; a canonical placeholder a translation drops
  *(warning)*
- **plural-coverage gaps** — a locale missing a category its CLDR rules require
  (`few`/`many` for Polish, …), which would render a blank string *(error)*
- **kind mismatches** — a key that's a plain string in one locale and a `$plural`
  in another *(error)*
- **malformed `$plural`** and **stale keys** not in the canonical locale

```
stele check — 3 locales, 657 keys (canonical: en)

  ✓ en
  ✗ es — 1 error(s), 1 warning(s)
      error    home.greeting  —  placeholder {{nombre}} is not in the canonical string — it will be undefined at runtime
      warning  home.tagline   —  key is not in the canonical locale — it will be ignored

✗ check failed — 1 error(s), 1 warning(s)
```

### Branching on gender (and other enums) — `$select`

When wording varies by a *linguistic* category — grammatical gender, formality —
use `$select`. It branches on a caller-supplied value, with `other` as the
required fallback:

```json
{
  "invited": {
    "$select": {
      "param": "gender",
      "cases": {
        "female": "{{name}} la invitó a pasear",
        "male":   "{{name}} lo invitó a pasear",
        "other":  "{{name}} le invitó a pasear"
      }
    }
  }
}
```
```ts
stele.invited({ gender: "female", name: "Ana" });
//             ^ typed as "female" | "male" | "other" — a typo is a compile error
```

The selector is emitted as a **literal union** of the case names, so call sites
get autocomplete and exhaustiveness. `stele check` verifies every locale provides
the same cases and the same selector (the value is caller-supplied and
language-independent, so all locales must handle it).

Keep `$select` for *language* branching — gender, formality (tú/usted). UI state
(`isBusy ? … : …`) belongs in your code, not the catalog.

## Status

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

- [x] JSON → serializable IR
- [x] TypeScript emitter (nested typed accessors, zero-runtime)
- [x] Swift emitter (idiomatic labeled args, nested structs)
- [x] **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)
- [x] Distribution — native binary via npm (`@stelegen/cli`) and crates.io (`stelegen`),
      cross-compiled for macOS/Linux/Windows by CI on each tag
- [x] Multi-file / folder locales (path-as-namespace, deep-merged)
- [x] Locale store (`store` target) — framework-agnostic single source of truth:
      `getLocale` / `setLocale` / `subscribeLocale` / `resolveLocale` /
      `initLocale`, observable + pluggable persistence, zero platform deps
- [x] Device / "system" mode — store tracks a `"system" | Locale` preference
      (`followDevice` / `isFollowingDevice` / `syncDevice`), so "follow the phone"
      and "remember my choice" stay distinct
- [x] React / React Native bindings (`react` target) — `useStele` / `useLocale` /
      `useFollowingDevice` bound to the store via `useSyncExternalStore`, no
      Provider, `setLocale` from anywhere re-renders consumers
- [x] `{{name}}` placeholders (double-brace, whitespace tolerant, literal-`{}`-safe)
- [x] Any-input / chosen-output key casing (`case` option) with collision + invalid-id checks
- [x] Configurable API binding name (`binding` option) — `stele.*` by default, one word renames the whole surface
- [x] Node package output (`[package]`) — compiled `.js` + `.d.ts` + `package.json`
      (import-by-name, `exports` map) instead of loose `.ts`; emit into
      `node_modules/<name>` for a zero-repo-footprint "codegen as a dependency" setup
- [x] `stele check` — cross-locale validation (missing/stale keys, placeholder
      drift, plural-category coverage, kind mismatches); non-zero exit for CI
- [x] `$select` — linguistic branching (gender / formality) as a literal-union
      param; cross-locale case consistency enforced by `stele check`
- [ ] More emitters (Kotlin, Go, Rust, Java)

## License

MIT © 2026 Brian Corbin