Translations for Rails — without translation keys.
E18n.t("Save", meaning: "to store")
E18n.t("Hello %{name}", name: current_user.name)
E18n.t(one: "1 item", other: "%{count} items", count: items.size)You write real copy directly in source code. Escriba derives a stable hash from
that copy (plus an optional meaning: to disambiguate short ambiguous strings,
plus the shape of any interpolations) and uses the hash as the I18n key under
the hood. Translations live in a database table, edited through a Rails engine
admin UI mounted in your host app.
Underneath it's still Rails I18n: pluralization rules, interpolation, fallback chains, and YAML translations all keep working untouched.
Status: alpha (
0.1.0). The public API may change before1.0.
Translation keys are an indirection nobody asked for. t("users.show.profile.edit_button")
forces every developer to invent a name, agree on a hierarchy, and then keep
that hierarchy in sync with what the UI actually says. The English copy is
written somewhere far from the code that displays it — usually in
config/locales/en.yml — and the two drift over time.
Escriba flips the relationship. Source code is the source of truth for the
"dev locale" (English by default). Translators don't translate keys; they
translate real strings, with a meaning: hint when context isn't obvious from
the string alone.
Requirements: Ruby ≥ 3.1, Rails ≥ 7.0.
Add to your Gemfile:
gem "escriba"Then:
bundle install
bin/rails generate escriba:install
bin/rails db:migrateMount the engine in config/routes.rb:
mount Escriba::Engine => "/escriba"Outside development and test, Rails will refuse to boot until you configure an
authentication hook in config/initializers/escriba.rb:
Escriba.configure do |config|
config.authenticate_with = ->(controller) do
controller.head :forbidden unless controller.current_user&.admin?
end
endThe callable runs as a standard Rails before_action in the engine's
controllers and receives the controller instance; halt the request by
rendering or redirecting from it.
Replace t("save_button") with E18n.t("Save"). That is the whole API for the
singular case. E18n.t works anywhere I18n.t works — views, controllers,
mailers, jobs, POROs.
<h1><%= E18n.t("Welcome back, %{name}", name: current_user.name) %></h1>
<%= submit_tag E18n.t("Save", meaning: "to store") %>
<p><%= E18n.t(one: "1 item", other: "%{count} items", count: cart.size) %></p>Strings are discovered at runtime: each one appears in the admin UI the first
time the host app renders it in production. Visit /escriba to translate
discovered strings into other locales. Translations become effective on the
next deploy.
There are two locale concepts:
-
dev_locale(defaults to:en) — the locale whose strings live inside the source code. In development and test this is short-circuited: Escriba returns the source string directly without touching the database. In production, the first access to a string seeds adev_localerow in the database with the source copy. -
All other locales — read from the database. Missing entries fall through Rails' normal I18n fallback chain, which lands on the
dev_locale— by default that's thedev_localerow in the DB (seeded on first access from the source string), or withdev_locale_from_code = trueit's the source string in code directly. Either way, an untranslated Spanish page in production shows English copy that ultimately originated in the source.
| Environment | Locale | Behavior |
|---|---|---|
| dev / test | == dev_locale |
Return source from code. No DB, no cache. |
| dev / test | != dev_locale |
Cache → DB → fallback chain (lands on source). On miss seeds dev_locale row. |
| production (default) | == dev_locale |
Cache → DB. On miss inserts source as value, returns source. |
| production (default) | != dev_locale |
Cache → DB. On miss seeds dev_locale row, returns nil → fallback chain. |
production, dev_locale_from_code = true |
== dev_locale |
Return source from code. No DB, no cache. |
production, dev_locale_from_code = true |
!= dev_locale |
Cache → DB. On miss seeds dev_locale row, returns nil → fallback chain (lands on source). |
Some teams want the dev_locale (e.g. en) editable in the admin UI like any
other locale — content writers iterate on copy without a deploy. Other teams
want copy changes to flow through pull requests so source code is the single
source of truth.
Set config.dev_locale_from_code = true to opt into the second mode. With it
on, the dev_locale always reads from source code regardless of environment —
just like dev/test does by default. The admin UI hides the dev_locale from
the editable tabs and refuses direct edit attempts. Discovery still works:
when a non-dev locale request misses in the DB, a dev_locale row is still
seeded so translators can see what strings exist.
dev_locale_from_code |
What changes |
|---|---|
false (default) |
dev_locale rows are editable in the admin UI; runtime reads them from the DB in production. |
true |
dev_locale always served from source code; admin UI shows the source as read-only. |
Translations are read once per process and held in a never-evicted in-memory cache. Edits in the admin UI persist to the database but do not invalidate caches in running processes — they become effective on the next deploy. The admin UI displays this as a banner.
This is a deliberate trade-off: it removes the need for cache invalidation plumbing (pub/sub, polling, version stamps) at the cost of translator latency. For most apps, translations change rarely; deploys are the natural refresh point.
E18n.t(copy, meaning: nil, **interpolation_values)copy— the source string. Required.meaning:— optional disambiguation hint. Two calls with the samecopybut differentmeaning:produce different keys (e.g."Save"as in "store" vs. "Save" as in "rescue").- Any other keyword arguments are forwarded to I18n as interpolation values.
E18n.t(zero: "...", one: "...", two: "...", few: "...", many: "...", other: "...",
count: n, meaning: nil, **interpolation_values)- At least
other:andcount:are required. - Other CLDR plural forms (
zero,one,two,few,many) are optional. English source typically suppliesoneandother; translators fill in whatever extra forms their target locale needs (Russian needsone/few/many/other, Arabic uses all six). - No positional argument — passing one alongside plural forms is a programmer error.
Escriba::ArgumentError is raised for malformed calls: missing copy in the
singular case, missing count: or other: in the plural case, or a positional
argument mixed with plural forms.
The I18n key is a SHA-256 hash, truncated to 16 hex characters, of a canonical form of the call. Before hashing:
- Whitespace is normalized — leading/trailing whitespace stripped; internal runs (including newlines and tabs) collapsed to a single space.
- Interpolation variable names are ordinalized —
%{name}is replaced by%{1},%{count}by%{2}, and so on, in left-to-right order of first appearance. Renaming%{name}to%{user_name}does not change the key, but adding or removing an interpolation does. - For plurals, the canonical form is the sorted hash of plural forms; key order at the call site doesn't matter.
meaning:is part of the hash and is also stored on the row for the translator UI.
Editing the source copy (typo fix, rewording) produces a new key and orphans the old translations. This is deliberate — see "Not supported" below.
Escriba.configure do |config|
config.dev_locale = :en # locale represented by source code
config.dev_locale_from_code = false # true = dev_locale always from code, not DB
config.authenticate_with = ->(c) { } # required outside dev/test
config.available_locales = %i[en es] # defaults to I18n.available_locales
endHardcoded (not configurable): the hash is always SHA-256 truncated to 16 hex
characters; the table is always escriba_translations.
Mounted at whatever path you chose (the install generator suggests /escriba).
Provides:
- A locale-tabbed index of all known strings, with a "show only missing" filter for non-source locales.
- A per-key view showing the source copy, the meaning, the interpolation variables (for reference), and the value in every available locale.
- An edit form per
(key, locale)pair — singular gets a textarea, plural gets one field per CLDR plural form. The source copy and meaning are shown as read-only context.
Edits do not invalidate running processes; the UI shows a banner explaining that changes go live on the next deploy.
- Static extraction. Strings are discovered at runtime — they appear in the
admin UI after the host app calls
E18n.t(...)for that string at least once. Static extraction (parsing Ruby + ERB to find allE18n.tcalls upfront) is a planned future addition. - Orphan detection. Strings whose source was deleted or edited stay in the database as orphans. This will be visualized in the admin UI once static extraction lands.
- Mid-process cache invalidation. Refresh happens on deploy. This is a deliberate simplification.
- HTML safety /
_htmlsuffix conventions. HTML safety is the host template layer's responsibility.
escriba/
├── gem/ The Rails engine gem.
└── dummy/ A Rails 8 app used for integration testing.
gem/lib/escriba/— the gem's core (key derivation, cache, backend, API).gem/app/— the engine's controllers, views, helpers.gem/config/routes.rb— the engine's routes.gem/lib/generators/escriba/install/— the install generator.gem/test/— Minitest unit tests.dummy/— a Rails 8 host app with demo controllers exercising every code path of the gem.
cd gem
bundle install
bundle exec rake testTo run the dummy app:
cd dummy
bundle install
bin/rails db:migrate
bin/rails serverVisit http://localhost:3000 for the demo pages, or
http://localhost:3000/escriba for the admin UI.
MIT.