Skip to content
carameldocs · 0.16.0
Browse documentation
Guides / Internationalization

Speak your users’ language.

Add typed translations, choose a language for each request, and localize numbers, dates, and validation messages in Caramel 0.15.0.

ContractValue
Release0.15.0
NeedsAn application that has run frappe migrate, whose config/application.cr still has one require "caramel" and one require "../app/views/application_view" line
You will haveTyped messages, a language for each request, and localized numbers, dates and validation messages
Proved byfrappe check · frappe translations · frappe corretto

Stage 1 · Start

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.

Terminal · inside your application
frappe make locale fr

Expect: Prints Generated for app/locales/en.cr, app/locales/fr.cr and config/application.cr, then a Next: line that says to write translations in app/locales/fr.cr, set dir: locale.dir in the layout for right-to-left languages, and run frappe translations. It refuses, naming the three lines to add by hand, when config/application.cr lacks exactly one require "caramel" and one require "../app/views/application_view".

The configuration now requires caramel/i18n after caramel, loads app/locales/*, then calls Caramel.locales default: "en" once, before loading views. A resource generated from now on writes t. calls and adds its messages to app/locales/en.cr. Resource views generated earlier keep their text until you replace it with message calls. The rest of this guide and its spec use a Book resource:

Terminal · inside your application
frappe make resource Book title:string
frappe migrate
frappe check

Expect: frappe make resource prints Generated for the model, changeset, actions, views, spec/requests/books_spec.cr, a migration, config/routes.cr, config/paths.cr and app/locales/en.cr, which gains a books: group. frappe migrate ends with The database matches the declared schema. frappe check exits 0.

Stage 2 · Write

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.

app/locales/en.cr · catalog excerpt
Caramel.locale "en", {
  home: {greeting: "Hello, %{name}!"},
  library: {
    count: {"=0": "No books", one: "%{count} book", other: "%{count} books"},
  },
}
app/locales/fr.cr · catalog excerpt
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. Use lowercase identifiers for keys.

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.

Inside a view’s blueprint
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.

Plurals follow CLDR cardinal rules for whole numbers. English requires one and other; French also requires many, which French selects for a non-zero multiple of a million, as in 2 000 000 de livres. Optional exact forms such as "=0" take priority, and %{count} uses its number separators.

Stage 3 · Choose

Choose the request language

A valid ?locale= switch on GET or HEAD changes the remembered language. Otherwise, selection follows this order:

  1. A non-default locale prefix, when enabled.
  2. The remembered locale cookie.
  3. The best supported Accept-Language match.
  4. The application’s default locale.

Locale codes include fr, pt-BR, and zh-Hant. To enable language prefixes, change the existing configuration call:

config/application.cr · replace the existing locales call
Caramel.locales default: "en", prefix: true

With 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, with Cache-Control: no-store, and remembers the choice in a cookie. Unknown switch values are ignored.

ConditionOutcome
An Accept-Language header over 1,024 bytesis ignored
An Accept-Language header with more than 16 rangesreads the first 16
The remembered localeis the __Host-caramel_locale cookie: Secure, HttpOnly, SameSite Lax, path /, 365 days

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.

Inside a view’s blueprint
nav(aria_label: "Language") do
  Caramel::Locale.each do |language|
    a(href: switch_locale_path(language), hx_boost: "false") { language.name }
  end
end

Set the layout’s html element to lang: Caramel.language; generated applications already have it. For a right-to-left language, add dir: locale.dir and use margin-inline and padding-inline in your styles, so they mirror with the direction.

Stage 4 · Localize

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.

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.

Inside a job’s perform method · locale is the payload field
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.
end

Stage 5 · Look up

Look up catalog keys

The reserved caramel section takes only these keys. A locale that leaves one out uses the default locale’s, then English.

KeyMeaning
languageThe language’s own name, such as Français
number.separator, number.delimiterOne character each; English uses . and ,
time.months, time.abbr_months12 names, January first
time.days, time.abbr_days7 names, Sunday first
time.am, time.pmThe text for %p
time.formats.NAMEA pattern; date, time, datetime and short_date are built in

A 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.

The errors and pages keys translate Caramel’s own messages:

KeyEnglish text
errors.requiredis required
errors.must_be_filemust be a file
errors.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} characters
errors.at_mostmust be at most %{max}
errors.at_most_charactersmust be at most %{max} characters
errors.at_least_itemsmust have at least %{min} item(s)
errors.at_most_itemsmust have at most %{max} item(s)
errors.duplicate_itemrepeats an earlier item
errors.duplicate_fieldDuplicate field: %{name}
errors.unknown_fieldUnknown field: %{name}
errors.expected_json_objectExpected a JSON object
errors.urlmust be an absolute http or https URL
errors.blankcan't be blank
errors.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 format
errors.invalidis invalid
errors.takenhas already been taken
errors.record_goneRecord no longer exists
errors.record_staleRecord changed since you loaded it
pages.check_requestCheck your request
pages.not_foundNot found
pages.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.

Stage 6 · Limits

What does not compile

Each mistake below stops the build at the line that causes it.

ConditionOutcome
A misspelled message name or a missing argument in a t. callfails compilation
Crystal string interpolation in catalog text instead of %{name} placeholdersfails compilation
A key that is not a lowercase identifier, a reserved word, or a malformed catalogfails compilation
A non-default locale that defines an application key the default locale lacksfails compilation
A non-default locale that changes a message’s placeholders or kindfails compilation
A locale that omits a required plural categoryfails compilation
A framework errors or pages key that changes the placeholders of its English textfails compilation
A time pattern that uses a directive outside the listed set, or malformed caramel.time or caramel.number datafails compilation
A non-default locale that adds a time format namefails compilation
A language without built-in plural rules and no plural:fails compilation

Not covered yet

Caramel supports typed text messages, whole-number plurals, number and time formatting, and request-local language selection. These are not implemented:

  • Select and gender messages.
  • Currency formatting.
  • Relative time formatting.
  • Translated URL segments.
  • Database-backed catalogs.
  • Automatic job locale propagation.
  • An unused-key report.

Read the internationalization design and API details ↗

Prove it

Check translations and requests

Use the completeness report alongside the compiler and request specs. frappe translations counts framework wording and formatting data as well as application text, so a newly generated locale has more to fill in than this guide’s catalog. Missing keys still compile and fall back to the default locale.

Use a fresh Corretto session to test browser language negotiation without a remembered locale. Add this example to the spec frappe make resource Book wrote; the generated layout already sets lang: Caramel.language:

spec/requests/books_spec.cr · a second example
it "answers in the browser’s language" do
  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") }
  end
end

Also assert translated text, fallback behavior, validation messages in HTML and JSON, switch redirects, and form submissions under a locale prefix.

Terminal · application checks
frappe check
frappe translations
frappe corretto

Passing: frappe check exits 0. frappe translations prints one MISSING fr KEY app/locales/fr.cr line per key the French catalog leaves on the default, such as MISSING fr books.collection app/locales/fr.cr, then N missing in 1 locales, and exits 1: with this guide’s catalog that is the outcome. Once fr has every key it prints OK 2 locales, N keys and exits 0. frappe corretto ends with Corretto: 1 of 1 workers passed, and the new example sees Content-Language: fr.