agentleFS
mickzijdelmickzijdel/rails-toolkit/skills/rails-i18n/SKILL.md

rails-i18n

Skill2 starsChanged 9 days agoMITView on GitHub

Use when adding or auditing I18n: locale files, pluralization, date, number or currency formats, translated errors, missing or unused keys (i18n-tasks). Triggers on \"i18n\", \"internationalization\", \"translate\", \"locale\", \"pluralize\", \"missing translation\", \"l10n\", \"add a language\".

The file

We split this skill into the rules it gives an agent

2 rules. About 0 are common, 0 are distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Keep translator-facing copy out of Ruby string literals entirely, including flash messages and raised user-facing errors — a literal is …

    line 50
  2. Very specific:

    Never string-interpolate a count into a hand-picked singular/plural — locales vary in plural rule count (English has 2, Arabic has 6).

    line 78

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Rails Internationalization (I18n)

1. Locale File Organization

Nest keys by the same path the code lives at — views.<controller>.<action>, <mailer>.<action>, activerecord.models/attributes/errors — so a translator (or grep) can find a string from its call site alone. One file per locale, not per feature; features already nest under the top-level key.

# config/locales/en.yml
en:
  boards:
    show:
      empty_state: "No cards yet. Add the first one."
    create:
      success: "Board created."
  activerecord:
    models:
      board: "Board"
      card:
        one: "Card"
        other: "Cards"
    attributes:
      card:
        title: "Title"
        due_on: "Due date"
    errors:
      models:
        card:
          attributes:
            title:
              blank: "can't be empty"
# config/application.rb
config.i18n.default_locale = :en
config.i18n.available_locales = %i[ en fr de ja ]
config.i18n.fallbacks = true                        # see Pattern 6
# config.i18n.load_path is auto-extended to config/locales/**/*.{rb,yml}; keep locale
# files there rather than adding a custom load_path unless a gem needs one.

Key Points:

  • Split by locale (en.yml, fr.yml), not by feature (boards.yml, cards.yml) — a locale file is a translator's unit of work; a feature split forces them to hop files mid-review.
  • For large apps, config/locales/en/boards.yml (subdirectory, still keyed en: at the top) is fine — Rails loads the whole tree. Don't invent a second top-level key per file.
  • Keep translator-facing copy out of Ruby string literals entirely, including flash messages and raised user-facing errors — a literal is invisible to i18n-tasks (Pattern 7) and silently ships untranslated.

2. Lazy Lookup — the Leading Dot

Inside a view, controller, or mailer, t(".key") infers the scope from the current template/action, so the same partial reused across pages doesn't need the full key spelled out. [[rails-mailers]] Pattern 3 covers this for mailer subjects; the same shorthand applies everywhere:

<%# app/views/boards/show.html.erb — resolves to boards.show.empty_state %>
<%= t(".empty_state") %>
class BoardsController < ApplicationController
  def create
    @board = Current.account.boards.create!(board_params)
    redirect_to @board, notice: t(".success")   # boards.create.success
  end
end

Lazy lookup only works from a view/controller/mailer context — helpers and models have no implicit scope, so spell the full key there (I18n.t("activerecord.models.card"), not a leading dot).


3. Pluralization

Never string-interpolate a count into a hand-picked singular/plural — locales vary in plural rule count (English has 2, Arabic has 6). Pass count: and let I18n pick the CLDR-correct key.

en:
  cards:
    count:
      zero: "No cards"
      one: "1 card"
      other: "%{count} cards"
t("cards.count", count: @cards.size)
# ❌ Bad — breaks in any locale with more than two plural forms, and reads awkwardly even in English at zero
"#{@cards.size} card#{'s' unless @cards.size == 1}"

ActiveRecord::Base#human_attribute_name and Model.model_name.human(count:) pick up activerecord.attributes.<model>.<attr> and activerecord.models.<model> the same way — a model translated with one/other (Pattern 1's card: example) renders correctly in both singular and collection contexts without a second lookup.


4. Number, Date, and Time Formatting

l (alias for I18n.l) formats Date/Time/ActiveSupport::TimeWithZone per-locale; number_to_currency/number_with_delimiter do the same for numbers. Both read named formats from the locale file instead of a hardcoded strftime.

en:
  date:
    formats:
      short_month_day: "%b %-d"     # "Jul 5"
  time:
    formats:
      short: "%b %-d, %l:%M%P"      # "Jul 5, 2:30pm"
  number:
    currency:
      format:
        unit: "$"
        delimiter: ","
        format: "%u%n"
<%= l @card.due_on, format: :short_month_day %>
<%= l @card.created_at, format: :short %>
<%= number_to_currency @invoice.total_cents / 100.0 %>

A locale file that only translates %b/%-d-style keys but never touches number.currency will silently render US-formatted currency in every locale — currency and number formats need their own locale entries, they don't fall out of the date/time ones.


5. Setting the Locale Per Request

Resolve the locale once per request from an explicit signal — URL param, subdomain, or a stored user preference — not solely from Accept-Language, which reflects the browser/OS, not necessarily what the signed-in user chose. Fall back to Accept-Language only for anonymous visitors.

# app/controllers/concerns/setting_locale.rb
module SettingLocale
  extend ActiveSupport::Concern

  included do
    around_action :switch_locale
  end

  private
    def switch_locale(&action)
      I18n.with_locale(locale_for_request, &action)
    end

    def locale_for_request
      params[:locale].presence ||
        Current.user&.locale.presence ||
        http_accept_locale ||
        I18n.default_locale
    end

    def http_accept_locale
      request.headers["Accept-Language"].to_s.scan(/^[a-z]{2}/).first
        &.to_sym
        &.then { |candidate| candidate if I18n.available_locales.include?(candidate) }
    end
end

I18n.with_locale scopes the override to the block and restores the previous value afterward — safe under threaded servers, unlike assigning I18n.locale = directly, which leaks across requests sharing a thread if an exception skips the reset.

Key Points:

  • Persist an explicit user choice (Current.user.update!(locale: params[:locale])) rather than re-deriving it from the browser every request — a user who prefers en on a fr browser shouldn't get overridden silently.
  • Validate params[:locale] against I18n.available_locales before using it — an unvalidated locale param is an easy way to trigger I18n::InvalidLocale in production or, worse, load an arbitrary locale file path.

6. Fallbacks for Partial Translations

New locales are rarely 100% translated on day one. config.i18n.fallbacks = true (Pattern 1) falls back missing keys to I18n.default_locale instead of raising or rendering translation missing. For locale families (regional variants), declare an explicit chain:

# config/initializers/i18n.rb
I18n::Backend::Simple.include(I18n::Backend::Fallbacks)
I18n.fallbacks[:"fr-CA"] = [ :"fr-CA", :fr, :en ]

Without fallbacks, a single missing key in a 95%-translated locale renders translation missing: fr.boards.show.new_feature_banner directly in production HTML — fallbacks degrade to English instead of leaking the raw key to users.


7. Catching Missing and Unused Keys with i18n-tasks

Locale files drift: keys get added in code but forgotten in non-default locales, or removed from views but left behind in en.yml. i18n-tasks finds both, and its health check is cheap enough to run in CI.

# Gemfile
group :development, :test do
  gem "i18n-tasks"
end
bin/rails g i18n-tasks:install     # writes config/i18n-tasks.yml

i18n-tasks missing               # keys used in code but absent from a locale
i18n-tasks unused                # keys defined but never referenced
i18n-tasks normalize             # re-sorts/reformats locale files consistently
i18n-tasks health                # missing + unused + inconsistent interpolations, one summary
# config/i18n-tasks.yml — the defaults miss dynamic keys; teach it your patterns
ignore_missing:
  - "activerecord.attributes.card.*"   # backfilled from schema, not hand-written
search:
  paths:
    - app/views
    - app/controllers
    - app/mailers

CI gate:

# .github/workflows/ci.yml
- run: bundle exec i18n-tasks health

i18n-tasks missing only catches keys reachable through its static scan of t("literal.key") calls — a dynamically built key (t("cards.status.#{card.status}")) needs a search.strict: false opt-in or an explicit ignore_missing entry, or every dynamic-key locale will look falsely clean.


8. Testing Translation Coverage

Rather than eyeballing rendered pages, raise on any missing key during the test run so a removed/renamed translation fails the suite instead of shipping a visible translation missing string:

# test/test_helper.rb (or spec/rails_helper.rb)
I18n.exception_handler = ->(exception, *) { raise exception }

For a single test asserting a specific translated string renders (not just "no exception"):

test "empty board shows the localized empty state" do
  I18n.with_locale(:fr) do
    get board_path(boards(:empty))
    assert_select "p", text: I18n.t("boards.show.empty_state", locale: :fr)
  end
end

Combine with i18n-tasks health in CI (Pattern 7) rather than choosing one — the exception handler catches keys hit during the tests that actually run; i18n-tasks catches keys no test path exercises at all.


Common Mistakes

MistakeWhy it's badFix
Hand-built plural strings ("#{n} card#{'s' if n != 1}")Wrong for locales with more than 2 plural forms; also wrong in English at zerot(key, count: n) with zero/one/other
I18n.locale = params[:locale] directly in a before_actionLeaks across requests on threaded servers if an exception skips resetting it; also an open redirect-style vector if unvalidatedI18n.with_locale(...) { } (Pattern 5), validated against available_locales
Literal English strings in flash messages / raised errorsInvisible to i18n-tasks, silently never translatedRoute every user-facing string through a locale file
No config.i18n.fallbacksOne missing key in a partially-translated locale renders the raw translation missing: ... key in productionEnable fallbacks (Pattern 6)
Currency/number formatting via hardcoded sprintf/roundIgnores locale-specific delimiters, currency symbol placementnumber_to_currency, number_with_delimiter, locale number.currency.format
Dynamic keys (t("status.#{status}")) with no i18n-tasks allowancei18n-tasks missing can't statically find them — locale gaps go undetected foreverignore_missing entry or enumerate the literal keys somewhere i18n-tasks can scan

Quick Reference

NeedReach for
String near a view/controller/mailert(".key") — lazy lookup
Pluralized stringt(key, count:) with zero/one/other in the locale file
Model/attribute nameModel.model_name.human, human_attribute_name — reads activerecord.*
Validation messageactiverecord.errors.models.<model>.attributes.<attr>.<error>
Date/timel(value, format: :name) + time.formats/date.formats in the locale file
Moneynumber_to_currency + number.currency.format
Per-request localeI18n.with_locale in an around_action, resolved from an explicit signal (Pattern 5)
Partially-translated localeconfig.i18n.fallbacks = true (+ explicit chain for regional variants)
Find missing/unused keysi18n-tasks missing / unused / health, gated in CI
Fail tests on a missing keyI18n.exception_handler = ->(e, *) { raise e } in the test helper

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.