Speak your users’ language.
Add typed translations, choose a language for each request, and localize numbers, dates, and validation messages in Caramel 0.7.1.
Enable translations
Internationalization is opt-in. Inside your application, add a locale before generating the resources you want translated. A plain application does not compile the i18n implementation.
frappe make locale fr
frappe checkThe first command creates app/locales/en.cr and app/locales/fr.cr and wires config/application.cr. New resource generation then writes t. calls and adds messages to the default catalog. Existing resource views keep their text until you replace it with message calls.
The configuration requires caramel/i18n after caramel, loads the locale files, then calls Caramel.locales default: "en" once, before loading views. If your configuration has been customized, follow the generator’s instructions to add that wiring by hand.
Write Crystal catalogs
Catalogs are Crystal named tuples. Add the groups below to the generated catalogs, keeping their existing common and resource messages. Each file has one Caramel.locale declaration; these excerpts show only the added groups. Set caramel.language to the language’s own display name.
Caramel.locale "en", {
home: {greeting: "Hello, %{name}!"},
library: {
count: {"=0": "No books", one: "%{count} book", other: "%{count} books"},
},
}Caramel.locale "fr", {
caramel: {
language: "Français",
number: {separator: ",", delimiter: " "},
time: {formats: {date: "%d/%m/%Y"}},
errors: {blank: "ne peut pas être vide"},
pages: {not_found: "Page introuvable"},
},
home: {greeting: "Bonjour, %{name} !"},
library: {
count: {
"=0": "Aucun livre",
one: "%{count} livre",
many: "%{count} de livres",
other: "%{count} livres",
},
},
}The default locale defines application keys and their placeholders. Other locales may omit keys and use the default text, but cannot add unknown application keys or change a message’s placeholders or kind. Use lowercase identifiers for keys; reserved words and malformed catalogs produce compiler errors at their source.
Call typed messages
Views and actions expose t, locale, and l, including inside markup. A message’s placeholders become named arguments. A plural takes its whole-number count first.
h1 { t.home.greeting(name: "Ann") }
p { t.library.count(3) }
p { l(1234.5, 2) }
p { l(Time.utc(2026, 10, 2), :date) }In French, these calls produce Bonjour, Ann !, 3 livres, 1 234,50, and 02/10/2026. Misspelled message names and missing arguments fail compilation. Use %{name} placeholders in catalog text rather than Crystal string interpolation.
Plurals follow CLDR cardinal rules for whole numbers. English requires one and other; French also requires many. Optional exact forms such as "=0" take priority. Each locale must supply its required categories, and %{count} uses its number separators.
Choose the request language
A valid ?locale= switch on GET or HEAD changes the remembered language. Otherwise, selection follows this order:
- A non-default locale prefix, when enabled.
- The remembered locale cookie.
- The best supported
Accept-Languagematch. - The application’s default locale.
Locale codes include fr, pt-BR, and zh-Hant. To enable language prefixes, change the existing configuration call:
Caramel.locales default: "en", prefix: trueWith this setting, /fr/books routes to /books in French. The default locale stays unprefixed; /en/books does not select English. Prefixes use lowercase codes, such as /pt-br/books. Generated resource path helpers already include the request locale’s prefix. Use localize_path("/account") for a manually written application path.
Routed responses identify their language with Content-Language. Responses selected without a prefix also vary by Accept-Language and Cookie. A language-switch request returns a 303 redirect, or a 200 response with HX-Redirect for htmx, and remembers the choice in a secure cookie. Unknown switch values are ignored.
Add a language switcher
Use each locale’s own name and let switch_locale_path preserve the current page and query. Reload the full page when switching so the layout and all visible text use the selected language.
nav(aria_label: "Language") do
Caramel::Locale.each do |language|
a(href: switch_locale_path(language), hx_boost: "false") { language.name }
end
endThe layout’s html element should use lang: Caramel.language; generated applications already do. Add dir: locale.dir when supporting right-to-left languages, and check your styles in both directions.
Format numbers and dates
l(number, decimals) uses caramel.number.separator and delimiter. l(time, :date) chooses a named time pattern; built-in names include :date, :time, :datetime, and :short_date. The default catalog can add more named formats.
Supply translated month and day names under caramel.time when your patterns use them. Formatting data falls back from the selected locale to the default locale, then to English. A time keeps its own location; formatting does not convert timezones. Unsupported directives and malformed formatting data fail compilation.
Translate errors and keep text safe
The reserved caramel.errors keys translate contract and changeset messages. caramel.pages includes check_request, not_found, and expired_form. The French catalog above translates blank validation errors and the router’s 404 body. Missing framework messages fall back through the default catalog to English.
Errors are translated when they are created. JSON keeps its errors object with arrays of messages per field, using the request’s locale. Framework wording is available through Caramel::Wording and SugarORM::Wording, including Caramel::Wording.expired_form.
Keep catalog messages as text. Views escape normal placeholder values. For a message containing a link or other markup, pass a markup { … } result as the placeholder: only that trusted HTML is emitted as markup, while catalog text and other values remain escaped.
Carry language into background work
A request’s locale belongs to its fiber and is restored afterward. Spawned fibers and Cold Brew jobs start in the default locale. Give a translated job a param locale : String, enqueue it with the request’s Caramel.language, and wrap the work that creates messages or errors in an explicit locale scope.
target = Caramel::Locale.parse?(locale) || Caramel::Locale.default
Caramel::I18n.with(target) do
messages = Caramel::Messages.new(target)
greeting = messages.home.greeting(name: "Ann")
# Use greeting in the job’s output.
endCheck translations and requests
Use the completeness report alongside the compiler and request specs. frappe translations prints MISSING code key file and exits 1 while translations are missing. It includes framework wording and formatting data, so a newly generated locale has more to fill in than application text. Missing keys can still compile and fall back intentionally.
frappe check
frappe translations
frappe correttoUse a fresh Corretto session to test browser language negotiation without a remembered locale. With a Book resource and a layout using Caramel.language:
Corretto.session do |client, db|
response = client.get("/books",
headers: {"Accept-Language" => "fr"})
response.should have_header("Content-Language", "fr")
response.should have_html { html(lang: "fr") }
endAlso assert translated text, fallback behavior, validation messages in HTML and JSON, switch redirects, and form submissions under a locale prefix.
Catalog reference
The reserved caramel section takes only these keys. A locale that leaves one out uses the default locale’s, then English.
languageThe language’s own name, such as Françaisnumber.separator, number.delimiterOne character each; English uses . and ,time.months, time.abbr_months12 names, January firsttime.days, time.abbr_days7 names, Sunday firsttime.am, time.pmThe text for %ptime.formats.NAMEA pattern; date, time, datetime and short_date are built inA pattern uses only %Y %m %-m %d %-d %H %-H %I %-I %M %S %p %B %b %A %a %%. %I is the 12-hour clock, and %- drops the leading zero. Only the default locale may add format names.
The errors and pages keys translate Caramel’s own messages. Each must keep exactly the placeholders its English text shows:
errors.requiredis requirederrors.must_be_filemust be a fileerrors.json_typemust be a JSON %{type}errors.invalid_valuemust be a valid %{type}errors.at_leastmust be at least %{min}errors.at_least_charactersmust be at least %{min} characterserrors.at_mostmust be at most %{max}errors.at_most_charactersmust be at most %{max} characterserrors.duplicate_fieldDuplicate field: %{name}errors.unknown_fieldUnknown field: %{name}errors.expected_json_objectExpected a JSON objecterrors.urlmust be an absolute http or https URLerrors.blankcan't be blankerrors.greater_thanmust be greater than %{than}errors.less_thanmust be less than %{than}errors.too_shortshould be at least %{min} character(s)errors.too_longshould be at most %{max} character(s)errors.invalid_formathas invalid formaterrors.invalidis invaliderrors.takenhas already been takenerrors.record_goneRecord no longer existspages.check_requestCheck your requestpages.not_foundNot foundpages.expired_formThis form has expired or came from another site. Reload the page and try again.Plural rules are built in for af am ar as ast az be bg bn bs ca cs cy da de el en es et eu fa fi fo fr fy ga gl gu he hi hr hu id is it ja ka kk km kn ko ky lb lo lt lv mk ml mn mr ms my nb ne nl nn no pl pt pt-PT ro ru sk sl so sq sr sv sw ta te th tr uk ur uz vi yue zh zu. A tag with a region or script uses its language’s rules, so pt-BR uses pt’s. For any other language, name one with the same rules: Caramel.locale "eo", {…}, plural: "en". locale.dir is rtl for ar he fa ur ps sd ug yi dv ckb.
Current scope
Caramel supports typed text messages, whole-number plurals, number and time formatting, and request-local language selection. Select/gender messages, currency and relative time formatting, translated URL segments, database-backed catalogs, automatic job locale propagation, and an unused-key report are not implemented.