How to: A thorough i18n completeness check in CI (Rails + i18n-tasks)

Posted . Visible to the public.

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-i18n or devise-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: and other:) 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: and other:) 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 diff runs two directions off different lists. Forward (base→locale) covers only what -l names; reverse (locale→base) covers every locale in the config, but only when -l contains the base locale. Keep them as explicit separate CI steps, or one direction vanishes the moment en drops out of a list.
  • Load order decides duplicates. Rails globs config/locales/**/*.{rb,yml}; the later file wins. An old_frontend.yml will silently override frontend.yml.
  • Put your I18n config into application.rb, not per-environment, or dev and test see different strings than production.
Profile picture of Klaus Weidinger
Klaus Weidinger
Last edit
Klaus Weidinger
License
Source code in this card is licensed under the MIT License.
Posted by Klaus Weidinger to makandra dev (2026-09-28 12:43)