Denne integrationsguide er kun tilgængelig på engelsk.
BeMyWords with Rails i18n
A complete integration guide for Rails applications using the standard I18n backend and YAML locale files.
Shape of the integration:
- At deploy time (or periodically), fetch translations from BeMyWords and write them as
config/locales/<lang>.yml. - Rails's built-in
I18n.treads those YAML files as normal. - Optionally, push your master English YAML to BeMyWords so translators work from a canonical source.
BeMyWords stores translations as flat key/value JSON. Rails expects nested YAML keyed by locale. This guide handles the conversion in both directions.
Before and after
The point of this integration is that you stop maintaining non-English locale files by hand. Concretely:
Before — every language is a file in your repo that a developer edits:
config/locales/
├── en.yml ← you add a key here
├── nb.yml ← …then remember to add it here
├── sv.yml ← …and here
└── de.yml ← …and here, usually with a TODO or the English string
# A new string means a pull request touching every locale file, and a
# separate round-trip to whoever actually speaks the language.
t("billing.invoice.overdue_notice")
# => "translation missing: nb.billing.invoice.overdue_notice"
After — English is the only locale file you write. The rest are build artifacts:
config/locales/
├── en.yml ← the only file you edit (or push from BeMyWords)
├── nb.yml ← generated by rake bemywords:fetch, gitignored
├── sv.yml ← generated
└── de.yml ← generated
# Add the key once, in English. The next deploy fetches every other
# language — already translated, reviewed in BeMyWords, with the
# glossary and do-not-translate terms applied.
t("billing.invoice.overdue_notice")
# => "Faktura forfalt til betaling"
The diff in your deploy configuration is one line:
release: |
bundle exec rails db:migrate
+ bundle exec rails bemywords:fetch
Nothing changes in your application code: I18n.t still reads YAML from config/locales, and there is no runtime dependency on BeMyWords. If the fetch fails, the previous YAML is still on disk and the app keeps serving the last known-good translations.
Prerequisites
- A Rails app with
I18nalready wired up (the Rails default). - A BeMyWords workspace, project, and namespace.
- A BeMyWords API key scoped to the project.
1. Environment variables
Add to .env (or your Rails.application.credentials if you prefer):
BEMYWORDS_BASE_URL=https://www.bemywords.no
BEMYWORDS_PROJECT_ID=<your-project-uuid>
BEMYWORDS_API_TOKEN=<your-api-token>
BEMYWORDS_NAMESPACE=app
2. Fetch translations via a Rake task
Create lib/tasks/bemywords.rake:
require "net/http"
require "json"
require "yaml"
namespace :bemywords do
desc "Fetch translations from BeMyWords and write config/locales/<lang>.yml"
task fetch: :environment do
base_url = ENV.fetch("BEMYWORDS_BASE_URL", "https://www.bemywords.no")
project_id = ENV.fetch("BEMYWORDS_PROJECT_ID")
token = ENV.fetch("BEMYWORDS_API_TOKEN")
namespace = ENV.fetch("BEMYWORDS_NAMESPACE", "app")
languages = I18n.available_locales.map(&:to_s)
languages.each do |lang|
uri = URI("#{base_url}/api/#{project_id}/latest/#{lang}/#{namespace}")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Token token=#{token}"
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
http.request(req)
end
unless res.is_a?(Net::HTTPSuccess)
warn " [#{lang}] HTTP #{res.code} — skipping"
next
end
flat = JSON.parse(res.body)
# BeMyWords uses dot-separated flat keys ("user.login.title").
# Rails YAML expects nested hashes under the locale code.
nested = flat.each_with_object({}) do |(key, value), hash|
parts = key.split(".")
cursor = hash
parts[0...-1].each do |part|
cursor[part] ||= {}
cursor = cursor[part]
end
cursor[parts.last] = value
end
out_path = Rails.root.join("config", "locales", "#{lang}.yml")
File.write(out_path, { lang => nested }.to_yaml)
puts " [#{lang}] wrote #{flat.size} keys to #{out_path.relative_path_from(Rails.root)}"
end
end
end
Add the generated files to .gitignore if you want them rebuilt on every deploy:
/config/locales/en.yml
/config/locales/nb.yml
Or keep them committed and update via CI — whichever matches your workflow.
3. Run it
bundle exec rails bemywords:fetch
After that, use I18n.t as you normally would:
# In a controller, view, or anywhere
I18n.t("user.login.title")
4. Wire it into your build / deploy
Heroku (release phase)
In Procfile:
release: bundle exec rails db:migrate bemywords:fetch
Any CI platform
Run bundle exec rails bemywords:fetch as a build step before assets:precompile.
Runtime fetch (not recommended)
If you want runtime fetching (translations change without a deploy), you'd typically use an on-disk cache plus a background refresh job. Out of scope for this guide — the build-time pattern is simpler, faster, and cheaper.
5. (Optional) Push English master to BeMyWords
To sync your English YAML to BeMyWords so translators have a canonical source:
# Add to lib/tasks/bemywords.rake
desc "Push English YAML to BeMyWords as master source"
task push_english: :environment do
base_url = ENV.fetch("BEMYWORDS_BASE_URL", "https://www.bemywords.no")
project_id = ENV.fetch("BEMYWORDS_PROJECT_ID")
token = ENV.fetch("BEMYWORDS_API_TOKEN")
namespace = ENV.fetch("BEMYWORDS_NAMESPACE", "app")
en_yaml = YAML.load_file(Rails.root.join("config", "locales", "en.yml"))
nested = en_yaml["en"]
# Flatten Rails-style nested YAML to BeMyWords-style dot-separated keys.
flat = {}
flatten = lambda do |h, prefix|
h.each do |k, v|
key = prefix.empty? ? k.to_s : "#{prefix}.#{k}"
if v.is_a?(Hash)
flatten.call(v, key)
else
flat[key] = v.to_s
end
end
end
flatten.call(nested, "")
uri = URI("#{base_url}/api/overwrite/#{project_id}/latest/en/#{namespace}")
req = Net::HTTP::Put.new(uri, "Content-Type" => "application/json")
req["Authorization"] = "Token token=#{token}"
req.body = { translations: flat }.to_json
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
http.request(req)
end
if res.is_a?(Net::HTTPSuccess)
puts "Pushed #{flat.size} English keys to BeMyWords."
else
warn "Push failed: HTTP #{res.code} — #{res.body}"
exit 1
end
end
Run when you've changed English master strings:
bundle exec rails bemywords:push_english
6. Handling Rails-specific features
Rails i18n pluralization
Rails i18n handles plurals with CLDR plural categories:
en:
apples:
one: "1 apple"
other: "%{count} apples"
In BeMyWords, store each plural form as a separate dot-key:
{
"apples.one": "1 apple",
"apples.other": "%{count} apples"
}
The fetch task nests these back under apples on output. No changes to how you call I18n.t("apples", count: 3).
Rails i18n interpolation
Rails uses %{name} placeholders. BeMyWords stores strings verbatim — placeholders pass through unchanged.
en:
greeting: "Hello, %{name}!"
Use placeholder validation in your BeMyWords project settings to catch translators who drop or mangle %{name}.
Rails i18n namespaces: per-engine or per-feature
If your app has engines or large feature silos, use separate BeMyWords namespaces per engine. Set BEMYWORDS_NAMESPACE dynamically in the rake task, or use multiple rake tasks (bemywords:fetch_main, bemywords:fetch_admin, etc.).
Rails i18n locale files (config/locales/*.yml)
Rails loads every config/locales/*.yml file into one translation store at boot, keyed by the top-level locale code. A minimal nb.yml looks like this:
nb:
billing:
invoice:
overdue_notice: "Faktura forfalt til betaling"
Three things about that structure are worth knowing before you automate it:
- The top-level key must match the file's locale, not the filename.
nb.ymlcontainingno:at the top will load a:nolocale andI18n.tunder:nbwill miss. - Nesting is arbitrary and yours.
billing.invoice.overdue_noticeis one key with two levels of grouping; Rails does not care how deep it goes. BeMyWords stores that as the flat keybilling.invoice.overdue_noticeand the fetch task in step 2 nests it back. config/locales/*.ymlis the default load path, not a rule. Subdirectories needconfig.i18n.load_path += Dir[Rails.root.join("config/locales/**/*.yml")]inapplication.rb.
Which files you should be hand-editing after this integration: en.yml (or whatever your source language is) and nothing else. The rest are generated output, and the point of the setup above is that they stop being files anyone edits.
Rails i18n fallbacks
While a language is partly translated, I18n.t raises translation missing for keys that have not landed yet. Fallbacks make it serve the source string instead:
# config/application.rb
config.i18n.fallbacks = [:en]
config.i18n.available_locales = %i[en nb da sv fi]
With fallbacks = [:en], a missing nb key falls back to English rather than rendering translation missing: nb.… to a customer. You can also chain per-locale — config.i18n.fallbacks = { "nb" => %i[da en] } — which is worth doing for closely related languages where a Danish string beats an English one.
Fallbacks matter more with this integration than without it, because translations arrive continuously rather than in one hand-edited batch. Two things to know:
- Fallbacks hide gaps, they do not close them. A page that silently renders English looks fine and is not translated. The BeMyWords checkup report is what tells you which keys are still falling back.
- Turn them on before your first partial fetch, not after the first bug report.
Finding missing translations (i18n-tasks)
i18n-tasks is the standard gem for auditing a Rails app's translation coverage — it scans your source for t() calls and reports keys that are used but missing, and keys that exist but are used nowhere.
bundle add i18n-tasks --group development
bundle exec i18n-tasks missing # used in code, absent from locale files
bundle exec i18n-tasks unused # in locale files, called nowhere
bundle exec i18n-tasks health
The two tools do different jobs and compose well:
i18n-tasksfinds the gaps in your English source. It is a static analysis of your own repo, and it is the right tool for "did I forget to add this key".- BeMyWords fills the other languages. It has no view of your source code; it works from the keys you push.
A useful order of operations: run i18n-tasks missing to find keys your code calls that en.yml lacks, add them in English, then run the push task from step 5 so the other languages follow on the next fetch. i18n-tasks unused is worth running before a push too — there is no reason to pay to store, or translate, keys nothing calls.
API reference
See the Astro integration guide — the API surface is identical, only the client differs.
Troubleshooting
KeyError: key not found: "BEMYWORDS_PROJECT_ID": env var not set. On Heroku, heroku config:set BEMYWORDS_PROJECT_ID=....
I18n::MissingTranslation in views after fetch: check the generated YAML structure. The flat-to-nested conversion in the rake task assumes dot-separated keys in BeMyWords correspond directly to the nested Rails structure. If you have dots in actual key names (rare), the conversion needs adjustment.
Pluralization shows translation missing: …: ensure all plural forms (one, other, plus few, many etc. for locales that need them) exist in BeMyWords for the target language.
Rails won't start because a YAML file is malformed: usually means the fetch ran with garbage data. Check the raw response from the API manually with curl.