{"id":51244508,"url":"https://github.com/briancorbin/stele","last_synced_at":"2026-07-03T07:01:21.020Z","repository":{"id":367620472,"uuid":"1281605395","full_name":"briancorbin/stele","owner":"briancorbin","description":"JSON-first, type-safe i18n codegen with pluggable per-language emitters. One JSON catalog → idiomatic typed accessors in every language. Protobuf for your copy.","archived":false,"fork":false,"pushed_at":"2026-06-28T13:22:39.000Z","size":234,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-30T04:25:04.194Z","etag":null,"topics":["codegen","i18n","internationalization","localization","react-native","rust","swift","type-safe","typescript"],"latest_commit_sha":null,"homepage":null,"language":"Rust","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/briancorbin.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-26T18:17:59.000Z","updated_at":"2026-06-29T17:34:34.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/briancorbin/stele","commit_stats":null,"previous_names":["briancorbin/stele"],"tags_count":12,"template":false,"template_full_name":null,"purl":"pkg:github/briancorbin/stele","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/briancorbin%2Fstele","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/briancorbin%2Fstele/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/briancorbin%2Fstele/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/briancorbin%2Fstele/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/briancorbin","download_url":"https://codeload.github.com/briancorbin/stele/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/briancorbin%2Fstele/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34993436,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-01T02:00:05.325Z","response_time":130,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["codegen","i18n","internationalization","localization","react-native","rust","swift","type-safe","typescript"],"created_at":"2026-06-29T03:02:26.091Z","updated_at":"2026-07-01T05:00:36.614Z","avatar_url":"https://github.com/briancorbin.png","language":"Rust","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Stele\n\n**JSON-first, type-safe i18n codegen with pluggable per-language emitters.**\n\nOne JSON catalog in. Idiomatic, fully-typed accessor code out — for *every* language your product ships in. Think \"protobuf for your copy.\"\n\n\u003e 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.\n\n---\n\n## The problem\n\nYou 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.\n\nExisting tools each solve half of this:\n\n- **typesafe-i18n** gives you nested typed accessors, but ships a runtime and is web-shaped.\n- **Paraglide** compiles to typed functions, but they're flat and web-shaped.\n- **SwiftGen / R.swift** are typed, but Swift-only.\n- **TMS platforms** (Tolgee, Crowdin, …) manage translations, then hand you *stringly-typed* native resources.\n\nNobody takes **one** JSON catalog and emits **type-safe, idiomatic, zero-runtime** accessors across **many** languages. That's Stele.\n\n## What you get\n\nAuthor your copy as plain, translator-friendly JSON:\n\n```json\n{\n  \"home\": {\n    \"title\": \"Sidewalk\",\n    \"greeting\": \"Hey {{name}}, there are dogs nearby\",\n    \"nearby\": {\n      \"$plural\": {\n        \"one\": \"{{count}} dog within {{radius}} of you\",\n        \"other\": \"{{count}} dogs within {{radius}} of you\"\n      }\n    }\n  }\n}\n```\n\nPlaceholders 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.\n\nRun `stele generate`, and call into it with full autocomplete and compile-time safety — in whichever language you're writing:\n\n**TypeScript**\n```ts\nconst stele = createStele(\"en\");\nstele.home.title;                              // \"Sidewalk\"\nstele.home.greeting({ name: \"Brian\" });        // typed; { nmae } is a compile error\nstele.home.nearby({ count: 3, radius: \"200m\" });\n```\n\n**Swift**\n```swift\nlet stele = Stele(.en)\nstele.home.title                               // \"Sidewalk\"\nstele.home.greeting(name: \"Brian\")             // idiomatic labeled args\nstele.home.nearby(count: 3, radius: \"200m\")\n```\n\nThe 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.\n\nSame 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.\n\n## Design principles\n\n- **JSON-first.** The JSON is the source of truth. Generated code is build output you can commit and review in PRs.\n- **Zero-runtime output.** Stele emits plain code — no proxy, no resolver, no runtime dependency. Tree-shakeable, bundler-friendly.\n- **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.\n- **Pluggable emitters.** A language-neutral, serializable IR is the contract. Adding a language is one emitter against that IR — the core never changes.\n\n## How it works\n\n```\nlocales/*.json  ──▶  parse  ──▶  IR (serializable)  ──▶  emitter  ──▶  generated code\n                                     │\n                                     ├─▶ TypeScript\n                                     ├─▶ Swift\n                                     └─▶ … (your language here)\n```\n\nThe 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).\n\n## Install\n\nThe `stele` binary ships as a native package — no Rust toolchain needed. Add it\nand run it as a build step:\n\n```bash\npnpm add -D @stelegen/cli        # or: npm i -D @stelegen/cli\n```\n\nThe right prebuilt binary for your platform is pulled in automatically (macOS\narm64/x64, Linux x64/arm64, Windows x64). Other ways in:\n\n```bash\ncargo install stelegen           # Rust users — installs the `stele` binary\ncargo build --release            # from a clone — ./target/release/stele\n```\n\n## Quickstart\n\nPoint `stele` at a config and generate, then commit the output and import it.\n\n```bash\nstele generate                   # reads ./stele.toml\nstele check                      # validate the catalog across locales (CI gate)\nstele ir --locales locales --canonical en   # inspect the IR\n```\n\n`stele.toml`:\n\n```toml\ncanonical = \"en\"\nlocales   = \"locales\"\n\n[[target]]\nlang = \"typescript\"\nout  = \"src/stele.gen.ts\"\n# callable = true               # emit no-arg leaves as () =\u003e \"...\" thunks\n# case = \"camel\"                # output identifier case (see below)\n# binding = \"stele\"             # brand name for the API (see below)\n\n[[target]]\nlang = \"swift\"\nout  = \"Sources/Stele.swift\"\n```\n\n### Locale layout\n\nA locale can be one flat file, or split across files and folders — the path\nbecomes the namespace, so you organize copy however your app is organized:\n\n```\nlocales/\n  en/\n    nav.json            →  stele.nav.*\n    walker/\n      today.json        →  stele.walker.today.*\n      schedule.json     →  stele.walker.schedule.*\n  es/\n    nav.json\n    ...\n```\n\n`locales/en.json` (one file) and `locales/en/` (a folder tree) both work, and may\neven be mixed; everything for a locale is deep-merged. A key defined twice across\nfiles is an error, never a silent clobber.\n\n### Key casing\n\nAuthor keys in **any** case — `walker_today`, `walkerToday`, `walker-today` all\nparse the same. The `case` option on a target picks the *output* identifier case,\napplied uniformly to namespaces, leaves, and params:\n\n| `case` | `walker_today` → | param `first_name` → |\n|---|---|---|\n| `camel` (default) | `stele.walkerToday` | `{ firstName }` |\n| `snake` | `stele.walker_today` | `{ first_name }` |\n| `pascal` | `stele.WalkerToday` | `{ FirstName }` |\n| `preserve` | verbatim | verbatim |\n\nIf two keys collapse to the same name under the chosen case (`dog_count` and\n`dogCount`), or a key isn't a valid identifier (`2fa`), generation fails loudly\nrather than emitting broken or silently-clobbered code.\n\n### API binding name\n\nThe generated API is branded `stele` by default — the accessor type is `Stele`,\nthe factory is `createStele`, and the `react` target exports `useStele` /\n`SteleProvider`. The `binding` option on a target renames that whole surface with\none word, applied uniformly across every language:\n\n| `binding` | TypeScript / Swift | React |\n|---|---|---|\n| `stele` (default) | `createStele` / `Stele` | `useStele`, `SteleProvider` |\n| `copy` | `createCopy` / `Copy` | `useCopy`, `CopyProvider` |\n| `t` | `createT` / `T` | `useT`, `TProvider` |\n\nIt's purely cosmetic — the emitted code is identical apart from those names — so a\nteam can pick the word that reads best at their call sites without any lock-in.\n\nWire it into your build so it can't drift:\n\n```jsonc\n// package.json\n\"scripts\": { \"copy:gen\": \"stele generate\" }\n```\n\nA CI check keeps generated output honest: `stele generate \u0026\u0026 git diff --exit-code`.\n\nA worked example — input, config, and generated output for both languages — lives in [`examples/`](examples/).\n\n### The locale store\n\nThe core factory (`createStele(locale)`) is pure — you pass the locale in. For an\napp you usually want *one* active locale that any code can read or change, that\npersists across launches, and that re-renders the UI when it flips. That's the\n`store` target: a tiny framework-agnostic module that owns the active locale and\nis the single source of truth.\n\n```toml\n[[target]]\nlang = \"typescript\"\nout  = \"src/stele.gen.ts\"\n\n[[target]]\nlang = \"store\"\nout  = \"src/stele.store.ts\"\ncore = \"./stele.gen\"          # import path to the typescript target's output\n```\n\n```ts\nimport {\n  getStele, getLocale, setLocale, subscribeLocale,  // read / write / observe\n  followDevice, isFollowingDevice, syncDevice,        // device / \"system\" mode\n  resolveLocale, initLocale,                          // startup helpers\n} from \"./stele.store\";\n\ngetStele().home.greeting({ name });   // accessor bound to the active locale\ngetLocale();                          // \"en\"\nsetLocale(\"es\");                      // pin Spanish — stops following the device, persists\nfollowDevice();                       // back to following the device locale, persists \"system\"\n```\n\nThe store tracks a **preference**, not just a locale: either `\"system\"` (follow\nthe device) or a pinned `Locale`. That's the difference between \"remember my\nchoice\" and \"follow my phone\" — keeping them distinct is what avoids the classic\nbug where a once-saved locale shadows the device forever.\n\n- **`resolveLocale(tags)`** maps arbitrary BCP-47 device tags to a supported\n  `Locale` (`[\"es-MX\",\"en-US\"] → \"es\"`), falling back to the canonical locale.\n- **`initLocale({ storage, deviceLocales })`** is the one-call startup: restore the\n  saved preference (or default to `\"system\"`), wire the device-locale source, and\n  apply it. Persistence is a pluggable adapter (`LocaleStorage`) storing\n  `\"system\" | Locale` — *you* hand it AsyncStorage / localStorage, so the store\n  pulls in no platform dependency.\n- **`syncDevice()`** re-reads the device locale *only while in system mode* — wire\n  it to an `AppState` \"active\" listener to track the OS language changing live.\n\n```ts\n// once, before your first render (e.g. behind a splash screen):\nawait initLocale({\n  storage: {                                          // ~3 lines you provide\n    load: () =\u003e AsyncStorage.getItem(\"locale\") as Promise\u003cLocalePref | null\u003e,\n    save: (p) =\u003e AsyncStorage.setItem(\"locale\", p),\n  },\n  deviceLocales: () =\u003e Localization.locales,          // expo-localization, navigator.languages, …\n});\n\n// follow the device with no persistence at all? just:\nawait initLocale({ deviceLocales: () =\u003e Localization.locales });\n```\n\n### React / React Native\n\nAdd a `react` target to bind the store to React via `useSyncExternalStore` —\nhooks only, **no Provider to mount**. `setLocale` from anywhere (a component or\nnot) re-renders every `useStele()` consumer. Works the same on web React and\nReact Native (no JSX build step).\n\n```toml\n[[target]]\nlang = \"react\"\nout  = \"src/stele.react.ts\"\nstore = \"./stele.store\"       # import path to the store target's output\n```\n\n```tsx\nimport { useStele, useLocale, useFollowingDevice } from \"./stele.react\";\n\n// in a component — re-renders when the locale changes:\nconst stele = useStele();\nconst [locale, setLocale] = useLocale();\nconst onSystem = useFollowingDevice();   // for a \"System\" radio in settings\nreturn \u003cText onPress={() =\u003e setLocale(\"es\")}\u003e{stele.home.greeting({ name })}\u003c/Text\u003e;\n```\n\n### As a node package (no files in your repo)\n\nInstead of loose `.ts` files, Stele can emit a **compiled package** — `.js` +\n`.d.ts` + `package.json` — via a `[package]` block. Point `out` straight at\n`node_modules/\u003cname\u003e/` and Stele writes a real, import-by-name package there;\nnothing lands in your source tree. It's the \"codegen as a dependency\" model, à la\nPrisma Client.\n\n```toml\n[package]\nname  = \"@myapp/copy\"\nout   = \"node_modules/@myapp/copy\"\nstore = true\nreact = true\n```\n\n```jsonc\n// package.json — regenerate on install (see the caveat below)\n\"scripts\": { \"postinstall\": \"stele generate\", \"copy:gen\": \"stele generate\" }\n```\n\n```ts\nimport { createStele }          from \"@myapp/copy\";\nimport { useStele, useLocale }  from \"@myapp/copy/react\";\nimport { getStele, initLocale } from \"@myapp/copy/store\";\n```\n\nStele emits the `.js` and `.d.ts` **directly** — no `tsc` in the loop, it stays\none binary. The `.js` is **CommonJS**, so React Native / Metro / Jest consume it\nfrom `node_modules` with **zero config** (ESM there would need\n`transformIgnorePatterns` allowlisting); ESM and bundler consumers still `import`\nit via interop. `tsc` trusts the `.d.ts`, and the package resolves by name through\nits `exports` map (`.`, `./store`, `./react`). A generated example lives in\n[`examples/out/pkg/`](examples/out/pkg/).\n\n\u003e CommonJS — not dual CJS+ESM — is deliberate: the locale store is a stateful\n\u003e singleton, and a dual package risks loading it twice (the dual-package hazard),\n\u003e forking the active locale. One format, one store.\n\n\u003e **`node_modules` is the package manager's turf.** `npm` / `pnpm` / `yarn install`\n\u003e prune directories they didn't create, so you must regenerate on `postinstall`.\n\u003e Under pnpm's strict store this is the same pattern — and the same caveats — as\n\u003e Prisma Client; Metro's resolver tends to tolerate it well for React Native. If\n\u003e your setup fights it, point `out` at a gitignored folder and add one resolver\n\u003e alias instead — same artifact, different home.\n\n### Validate the catalog — `stele check`\n\nGeneration makes the *current* locale type-safe; `stele check` keeps the whole\ncatalog honest as you add locales. It compares every locale against the canonical\none and exits non-zero on problems — wire it into CI:\n\n```bash\nstele check            # errors fail; warnings don't\nstele check --strict   # warnings fail too\n```\n\nIt catches:\n\n- **missing translations** — a canonical key absent in another locale *(error)*\n- **placeholder drift** — `{{nombre}}` where the canonical says `{{name}}` would\n  be `undefined` at runtime *(error)*; a canonical placeholder a translation drops\n  *(warning)*\n- **plural-coverage gaps** — a locale missing a category its CLDR rules require\n  (`few`/`many` for Polish, …), which would render a blank string *(error)*\n- **kind mismatches** — a key that's a plain string in one locale and a `$plural`\n  in another *(error)*\n- **malformed `$plural`** and **stale keys** not in the canonical locale\n\n```\nstele check — 3 locales, 657 keys (canonical: en)\n\n  ✓ en\n  ✗ es — 1 error(s), 1 warning(s)\n      error    home.greeting  —  placeholder {{nombre}} is not in the canonical string — it will be undefined at runtime\n      warning  home.tagline   —  key is not in the canonical locale — it will be ignored\n\n✗ check failed — 1 error(s), 1 warning(s)\n```\n\n### Branching on gender (and other enums) — `$select`\n\nWhen wording varies by a *linguistic* category — grammatical gender, formality —\nuse `$select`. It branches on a caller-supplied value, with `other` as the\nrequired fallback:\n\n```json\n{\n  \"invited\": {\n    \"$select\": {\n      \"param\": \"gender\",\n      \"cases\": {\n        \"female\": \"{{name}} la invitó a pasear\",\n        \"male\":   \"{{name}} lo invitó a pasear\",\n        \"other\":  \"{{name}} le invitó a pasear\"\n      }\n    }\n  }\n}\n```\n```ts\nstele.invited({ gender: \"female\", name: \"Ana\" });\n//             ^ typed as \"female\" | \"male\" | \"other\" — a typo is a compile error\n```\n\nThe selector is emitted as a **literal union** of the case names, so call sites\nget autocomplete and exhaustiveness. `stele check` verifies every locale provides\nthe same cases and the same selector (the value is caller-supplied and\nlanguage-independent, so all locales must handle it).\n\nKeep `$select` for *language* branching — gender, formality (tú/usted). UI state\n(`isBusy ? … : …`) belongs in your code, not the catalog.\n\n### Translator workflow — `export` / `import`\n\nJSON is friendlier than code, but professional translators don't hand-edit JSON\neither — they use spreadsheets or TMS/CAT tools that speak **CSV** and **XLIFF**.\nSo Stele round-trips through both, keeping the JSON catalog as the source of truth:\n\n```bash\nstele export --locale es --format csv    --missing   # only what's untranslated\nstele export --locale es --format xliff              # for a TMS / CAT tool\n# … translator fills in the target column / \u003ctarget\u003e …\nstele import es.csv                                   # writes locales/es.json\nstele check                                           # confirm it's complete\n```\n\nTwo things make this robust:\n\n- **Plural slots are per-locale.** Exporting Polish gives `few`/`many` rows English\n  never had; exporting Arabic gives all six categories — each from the target's CLDR\n  rules, so the translator can't under-translate a plural.\n- **Import can't reshape your catalog.** Structure (which keys are `$plural`, a\n  `$select`'s `param`) comes from the *canonical* catalog; only the strings come\n  from the translation file. A translator literally cannot rename a param or drop a\n  plural form — at worst a string is missing, and `stele check` catches that.\n\nPlaceholders travel as literal `{{name}}` text (translators preserve them). Import\n**mirrors the canonical locale's on-disk layout** — a single `locales/\u003cloc\u003e.json`,\nor, for a folder-split catalog (`locales/en/walker/today.json`), each key routed\ninto the matching target file (`locales/\u003cloc\u003e/walker/today.json`) — merging at the\nleaf level so existing translations are preserved.\n\n## Status\n\nEarly, but real. Both emitters are verified end-to-end: the generated code compiles, runs, and rejects bad calls at compile time.\n\n- [x] JSON → serializable IR\n- [x] TypeScript emitter (nested typed accessors, zero-runtime)\n- [x] Swift emitter (idiomatic labeled args, nested structs)\n- [x] **ICU4X**-backed plurals — authoritative CLDR rules baked into per-locale\n      tables at generate time, validated against the oracle, emitted as pure\n      lookups (correct `one`/`few`/`many` for Polish, Arabic, Russian, …; no\n      runtime `Intl.PluralRules`, so it's Hermes-safe)\n- [x] Distribution — native binary via npm (`@stelegen/cli`) and crates.io (`stelegen`),\n      cross-compiled for macOS/Linux/Windows by CI on each tag\n- [x] Multi-file / folder locales (path-as-namespace, deep-merged)\n- [x] Locale store (`store` target) — framework-agnostic single source of truth:\n      `getLocale` / `setLocale` / `subscribeLocale` / `resolveLocale` /\n      `initLocale`, observable + pluggable persistence, zero platform deps\n- [x] Device / \"system\" mode — store tracks a `\"system\" | Locale` preference\n      (`followDevice` / `isFollowingDevice` / `syncDevice`), so \"follow the phone\"\n      and \"remember my choice\" stay distinct\n- [x] React / React Native bindings (`react` target) — `useStele` / `useLocale` /\n      `useFollowingDevice` bound to the store via `useSyncExternalStore`, no\n      Provider, `setLocale` from anywhere re-renders consumers\n- [x] `{{name}}` placeholders (double-brace, whitespace tolerant, literal-`{}`-safe)\n- [x] Any-input / chosen-output key casing (`case` option) with collision + invalid-id checks\n- [x] Configurable API binding name (`binding` option) — `stele.*` by default, one word renames the whole surface\n- [x] Node package output (`[package]`) — compiled `.js` + `.d.ts` + `package.json`\n      (import-by-name, `exports` map) instead of loose `.ts`; emit into\n      `node_modules/\u003cname\u003e` for a zero-repo-footprint \"codegen as a dependency\" setup\n- [x] `stele check` — cross-locale validation (missing/stale keys, placeholder\n      drift, plural-category coverage, kind mismatches); non-zero exit for CI\n- [x] `$select` — linguistic branching (gender / formality) as a literal-union\n      param; cross-locale case consistency enforced by `stele check`\n- [x] Translator round-trip (`stele export` / `import`) — CSV + XLIFF 1.2,\n      per-locale plural slots, structure-locked import that mirrors single-file\n      or folder-split layout; JSON stays canonical\n- [ ] More emitters (Kotlin, Go, Rust, Java)\n\n## License\n\nMIT © 2026 Brian Corbin\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbriancorbin%2Fstele","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fbriancorbin%2Fstele","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbriancorbin%2Fstele/lists"}