This card describes a thorough CI setup for checking localization completeness. It supports multiple tiers (examples show 2 tiers) of locales (e.g. a public site with many locales and an admin area with fewer locales).
Gems: i18n-tasks, rails-i18n, optionally devise-i18n or other <gem>-i18n translations.
File layout
The Rails default puts a file per language into the top-level config/locales/ directory. This gets cumbersome as soon as you support more than 20 languages. Simply nest everything into config/locales/<locale>/.
config/locales/<locale>/shared.yml # both/all tiers: defaults.*, date/time/number formats
config/locales/<locale>/frontend.yml # public site
config/locales/<locale>/backend.yml # admin area, only required for a subset of locales
Note
You can still split files however you like. The app, that this setup was extracted from, had:
- 2 shared files
- 1 frontend exclusive file
- 6 backend exclusive files
- sometimes additional files if a language wasn't covered by
rails-i18nordevise-i18n
config/initializers/i18n.rb
Derive the tiers from the locale files, so a new language is covered the moment its translations are added. Choose a prominently used probe key for each translation tier.
module I18n
module_function
def available_frontend_locales
@available_frontend_locales ||= available_locales.select { |locale|
I18n.exists?('some.frontend_only.key', locale: locale, fallback: false)
}.sort
end
def available_backend_locales
@available_backend_locales ||= available_locales.select { |locale|
I18n.exists?('some.backend_only.key', locale: locale, fallback: false)
}.sort
end
end
config/i18n-tasks.yml (backend scope, the default)
<%
require 'i18n'
I18n.load_path += Dir['config/locales/**/*.yml']
I18n.backend.load_translations
load 'config/initializers/i18n.rb'
%>
base_locale: en
locales: <%= I18n.available_backend_locales.map(&:to_s).inspect %>
data:
read:
- config/locales/%{locale}/*.yml
external:
# Gem-provided data: counts as present, never reported unused, never written.
- "<%= %x[bundle info rails-i18n --path].chomp %>/rails/locale/%{locale}.yml"
- "<%= %x[bundle info devise-i18n --path].chomp %>/rails/locales/%{locale}.yml"
yaml:
write:
line_width: -1
search:
exclude:
- app/assets
- app/javascript
ignore_missing:
# Only for keys that are intentionally optional ...
- 'intentionally.optional'
# ... or untranslated gems, e.g. an English-only internal super-admin area
- 'active_admin.*'
ignore_unused:
# Resolved dynamically or by Rails, so the static scanner cannot see them.
- 'activerecord.{models,attributes}.*'
- 'activerecord.errors.{models,messages}.*'
- 'errors.messages.*'
- '{date,time,datetime,number}.*'
- '{devise,pagy}.*'
config/i18n-tasks-frontend.yml (frontend scope)
Same ERB header. Differs only in locales: and a narrower data.read.
<%
require 'i18n'
I18n.load_path += Dir['config/locales/**/*.yml']
I18n.backend.load_translations
load 'config/initializers/i18n.rb'
%>
base_locale: en
locales: <%= I18n.available_frontend_locales.map(&:to_s).inspect %>
data:
read:
- config/locales/%{locale}/shared.yml
- config/locales/%{locale}/frontend.yml
external:
- "<%= %x[bundle info rails-i18n --path].chomp %>/rails/locale/%{locale}.yml"
yaml:
write:
line_width: -1
Why two configs: ignore_missing is not locale-scoped, so "frontend-only locales need not carry backend namespaces" can only be expressed by a second config with a narrower data.read.
bin/i18n-check
#!/usr/bin/env ruby
require "rubygems"
require "bundler/setup"
require "active_support"
require "active_support/continuous_integration"
FRONTEND_CONFIG = "config/i18n-tasks-frontend.yml"
require "i18n"
I18n.load_path += Dir["config/locales/**/*.yml"]
I18n.backend.load_translations
load "config/initializers/i18n.rb"
# Locales that may lag behind on purpose (e.g. simplified German that falls back to regular German).
# They are still held to the subset checks.
LENIENT = %w[]
BACKEND_COMPLETENESS_LOCALES = (I18n.available_backend_locales.map(&:to_s) - %w[en] - LENIENT).join(",")
FRONTEND_COMPLETENESS_LOCALES = (I18n.available_frontend_locales.map(&:to_s) - %w[en] - LENIENT).join(",")
ActiveSupport::ContinuousIntegration.run("i18n", "Translation completeness") do
# === Axis 1: `en` against the code ===
step "No missing keys (`en` vs. code)",
"bundle", "exec", "i18n-tasks", "missing", "-t", "used", "-l", "en", "-f", "keys"
step "No unused keys (`en` vs. code)",
"bundle", "exec", "i18n-tasks", "unused", "-l", "en", "-f", "keys"
# === Axis 2: every other locale against `en` ===
step "Locales subset of `en` (backend)",
"bundle", "exec", "i18n-tasks", "missing", "-t", "diff", "-l", "en", "-f", "keys"
step "Locales subset of `en` (frontend)",
"bundle", "exec", "i18n-tasks", "-c", FRONTEND_CONFIG, "missing", "-t", "diff", "-l", "en", "-f", "keys"
step "Locales complete vs. `en` (backend)",
"bundle", "exec", "i18n-tasks", "missing", "-t", "diff", "-l", BACKEND_COMPLETENESS_LOCALES, "-f", "keys"
step "Locales complete vs. `en` (frontend)",
"bundle", "exec", "i18n-tasks", "-c", FRONTEND_CONFIG, "missing", "-t", "diff", "-l", FRONTEND_COMPLETENESS_LOCALES, "-f", "keys"
step "Interpolation variables consistent (backend)",
"bundle", "exec", "i18n-tasks", "check-consistent-interpolations", "-f", "keys"
step "Interpolation variables consistent (frontend)",
"bundle", "exec", "i18n-tasks", "-c", FRONTEND_CONFIG, "check-consistent-interpolations", "-f", "keys"
# === Individual files ===
step "Plural forms complete (backend)",
"bundle", "exec", "i18n-tasks", "missing", "-t", "plural", "-f", "keys"
step "Plural forms complete (frontend)",
"bundle", "exec", "i18n-tasks", "-c", FRONTEND_CONFIG, "missing", "-t", "plural", "-f", "keys"
step "Locale files normalized (backend)",
"bundle", "exec", "i18n-tasks", "check-normalized"
step "Locale files normalized (frontend)",
"bundle", "exec", "i18n-tasks", "-c", FRONTEND_CONFIG, "check-normalized"
end
Things this CI setup doesn't catch
- Hardcoded strings in code — they never reach a locale file.
- Translation quality.
- Format strings that are valid but wrong.
- Plural values. Only the presence of the required CLDR forms (e.g.
one:andother:) per language is checked.
- A locale that got started by copying
en: zero missing keys, entirely untranslated. - A key filed in the wrong tier. An English frontend key in a backend file is invisible to the frontend config, so it is never demanded of other frontend translations and silently falls back to English.
- Dynamically built keys (
t("prefix.#{var}")). They are reported unused unless explicitly listed. - A key that contains a plain string instead of multiple plural forms (e.g.
one:andother:) will sneak past the "plural forms" check. - The same key defined in two files of one locale. The later file silently wins.
Other gotchas
-
missing -t diffruns two directions off different lists. Forward (base→locale) covers only what-lnames; reverse (locale→base) covers every locale in the config, but only when-lcontains the base locale. Keep them as explicit separate CI steps, or one direction vanishes the momentendrops out of a list. -
Load order decides duplicates. Rails globs
config/locales/**/*.{rb,yml}; the later file wins. Anold_frontend.ymlwill silently overridefrontend.yml. -
Put your I18n config into
application.rb, not per-environment, or dev and test see different strings than production.