Internationalization
Every string Plutonium renders comes from a locale file, and every label it derives from a key (an action, a scope, a filter, a kanban column, a wizard step, a field's placeholder) can be supplied by one. Nothing in a definition has to change for an app to translate its UI or reword the defaults.
Where the strings live
Plutonium ships its own text under config/locales/en/*.yml in the gem, all under the plutonium namespace. Rails loads it at the lowest precedence:
- The host application's
config/localeswins over everything. - Package and portal engines'
config/locales, which Rails loads automatically. - The gem's defaults.
So a portal can reword a Plutonium string for itself, and the app can override the portal.
To change a fixed string, copy its key into your own locale file. The gem's locale files are the catalogue:
# config/locales/en.yml
en:
plutonium:
boolean:
"true": "On"
"false": "Off"Pagination is the one surface with its own dictionary. The info sentence, the per-page sentence and the nav aria labels come from Pagy's locale files, which cover about thirty-five languages. Override a Pagy key by adding a file to Pagy::I18n.pathnames, not through Rails I18n.
Switching locale
Plutonium reads I18n.locale on every render and never sets it. Set the locale the way any Rails app does, typically an around_action in the portal's controller concern:
module AdminPortal
module Concerns
module Controller
extend ActiveSupport::Concern
included do
around_action :switch_locale
end
def switch_locale(&)
I18n.with_locale(current_user&.locale || I18n.default_locale, &)
end
end
end
endThe Pagy locale is synced to I18n.locale on each pagination render, so the same switch covers pagination.
Models and attributes
Model and attribute names follow the Rails convention and already drive every label, column header, page title and flash:
en:
activerecord:
models:
blogging/post:
one: "Post"
other: "Posts"
attributes:
blogging/post:
title: "Title"
published_at: "Published"The model key is model_name.i18n_key: blogging/post for Blogging::Post. When a model defines one and other, Plutonium uses them for the plural forms in page titles, empty states and pagination. Without them it falls back to English inflection.
Fields: placeholder, hint, description
A definition never has to declare help text to make it translatable. When an input or display leaves a slot blank, Plutonium looks it up by convention:
en:
plutonium:
fields:
blogging/post:
title:
placeholder: "A short, descriptive title"
hint: "Shown in search results"
body:
description: "Rendered as Markdown"placeholder and hint apply to forms. description applies to displays. The lookup walks the model's ancestors like human_attribute_name does, so a key on blogging/post also serves an STI subclass.
For each slot the resolution order is:
- An explicit option on
inputordisplay, then onfield. plutonium.portals.<portal>.fields.<model>.<attr>.<slot>when rendering inside a portal.plutonium.fields.<model>.<attr>.<slot>.helpers.placeholder.<model>.<attr>, the Railsform_withconvention, for placeholders only.- Nothing.
An explicit option can still be a translation. Use the definition's class-level t, which returns a lazy value resolved on every render in the request's locale:
class PostDefinition < Plutonium::Resource::Definition
input :email, placeholder: t("forms.shared.email_placeholder")
input :quantity, hint: t("forms.stock.hint", count: 3)
endt is sugar over the proc form, which also works and has always been locale-safe:
input :email, placeholder: -> { I18n.t("forms.shared.email_placeholder") }Never call I18n.t directly in a class body. It runs once, at load time, in whatever locale happened to be active. Literal strings remain allowed and are used as they are.
The page-title setters take the same values. Pass a literal to fix it, or the lazy t to translate it per request:
index_page_title t("blog.index.title")
index_page_description t("blog.index.description")When a definition sets no title, the page falls back to the resource's translated model name.
Derived labels
Actions, scopes, filters, kanban columns and wizard steps default to a humanized version of their key. Each has a convention key that takes precedence over that fallback, and a label: option that takes precedence over both. For an action backed by an interaction, the interaction's explicit presents label: also counts as declared and wins over the convention; only the class-name default yields to it.
en:
plutonium:
actions:
blogging/post:
publish: "Publish now"
scopes:
blogging/post:
drafts: "Drafts"
filters:
blogging/post:
author: "Written by"
kanban_columns:
blogging/post:
in_review: "In review"
wizard_steps:
onboarding_wizard:
billing: "Billing details"The model segment is the resource's model_name.i18n_key. For wizard steps it is the wizard's model_name.i18n_key.
Enum values shown as badges, and the value pills of a select filter, resolve the way Rails resolves enum attribute values, with a Plutonium key as an alternative:
en:
activerecord:
attributes:
blogging/post:
status/draft: "Draft"
status/published: "Live"
plutonium:
values:
blogging/post:
status:
archived: "Archived"Portals
Every convention key has a portal-scoped variant under plutonium.portals.<portal>, where <portal> is the package namespace (admin_portal). It wins over the global key while rendering inside that portal, and the main app has no portal segment.
Portal packages are Rails engines, so their config/locales directory loads automatically. The portal generator scaffolds packages/<portal>/config/locales/en.yml with the prefix in place:
# packages/admin_portal/config/locales/en.yml
en:
plutonium:
portals:
admin_portal:
fields:
blogging/post:
title:
placeholder: "Internal working title"
actions:
blogging/post:
publish: "Publish to site"Feature packages get the same file for their models' names, attributes and field text. Keys without the portal prefix apply everywhere, and load order settles conflicts: the app's files beat a package's, and a package's beat the gem's.
Locale keys vary by portal, never by tenant. Tenant-specific wording is a custom I18n backend concern and is out of scope.
JavaScript
The Stimulus controllers bundled with the gem read their strings from a JSON blob the layout renders once per page, in the current locale:
<meta name="pu-i18n" content="{"clipboard":{"copied":"Copied!"},...}">The blob is the plutonium.js subtree of the locale merged over the default locale, so overriding a key in YAML changes the bundled JavaScript's text without rebuilding it, and a partially translated locale falls back per key. Host code can read the same blob:
window.Plutonium.t("plutonium.js.turbo_confirm.confirm")
window.Plutonium.t("plutonium.js.attachment_input.delete_all", { count: 3 })Third-party widgets keep their own locale mechanisms. The blob carries optional pass-through settings under plutonium.js.libraries:
| Key | Passed to |
|---|---|
slim_select | Slim Select settings (placeholderText, searchText, searchPlaceholder, searchingText) |
flatpickr.locale | flatpickr's locale option. Loading the matching l10n bundle is the host's job. |
uppy.strings | Uppy locale.strings |
intl_tel_input | intl-tel-input i18n |
Auth
Rodauth's own text (labels, buttons, flashes, email subjects) is translated by the rodauth-i18n gem, which Plutonium's Rodauth views pick up unchanged because they render through Rodauth's configuration methods. The mailer templates the Rodauth generator copies into your app are plain ERB and are yours to translate.
Testing
Turn on missing-translation errors in the test environment so a rendered component with a missing key fails instead of printing its key:
# config/environments/test.rb
config.i18n.raise_on_missing_translations = trueConvention lookups are exempt. They probe with default: nil and render nothing when a key is absent, which is the intended behaviour, so they never raise.
Adding a language
For a second locale, provide:
- A rails-i18n locale for Rails' own strings, dates and numbers.
- A Pagy dictionary for pagination, which Pagy ships for most languages.
rodauth-i18nif you use Rodauth.- A
plutoniumnamespace translated from the gem'sconfig/locales/en/*.yml. - Your models, attributes and field text under the keys above.
Plutonium itself ships only en.
