Localization (§12)

Day embeds Fluent catalogs and generates Rust accessors for their messages. The runtime resolves each message using a shared locale signal and the catalog attached to the accessor. The localization guide covers setup and translation workflows. This reference defines the generation, lookup, and tooling contracts.

Catalog generators

Entry point in build.rsOutput in OUT_DIRInclude macroLookup
day_build::prebuild_project()day_resources.rsday::resources!()Global app/core catalogs
day_build::generate_locales()day_locales.rsday_fluent::locales!()Private crate/file catalog

Both generators normally read resource/locales/ relative to the crate and emit embedded Fluent sources. The app resource generator can read a merged flavor tree through DAY_RESOURCE_ROOT. Cargo tracks the resource inputs, so changes regenerate the code on the next build. The generated files belong in Cargo’s output directory and are not edited by hand.

prebuild_project() also emits image, vector, asset, and font names. Its localization API is unchanged: files within a locale concatenate into one source, and res::str accessors use the global tr lookup. The app supplies res::locales::CATALOG through WindowOptions::locales. See resources for the other generated modules.

generate_locales() requires day-fluent at runtime. It reads the crate’s resource directory even when an app build sets DAY_RESOURCE_ROOT. An app’s flavor overlay therefore does not replace its dependencies’ catalogs. Private catalog generation does not currently apply flavor overlays.

Private catalogs for reusable crates and source modules

The private generator accepts resource/locales/<locale>/<file>.ftl. A filename can correspond to a Rust source module, but there is no source-file discovery step.

SourceAccessorCatalog descriptorScript key
app.ftl, message game_titleres::str::game_title()res::locales::SCOPEblockblast::game_title
board.ftl, message movesres::board::str::moves(count)res::board::locales::SCOPEblockblast::board::moves

These examples assume the Cargo package is named blockblast. Script namespaces use the package name with hyphens replaced by underscores, even if a dependent crate uses a Cargo alias for that package.

Each named file imports the same locale’s app.ftl messages and terms. The generated module also exposes accessors for imported messages. A missing app.ftl is allowed. Duplicate entries within a file, or collisions between a named file and its imports, fail generation. Sibling files can reuse message names and use different argument lists.

File stems must be Rust identifiers. app selects the crate-wide catalog. str, locales, self, Self, super, and crate are reserved. Nested directories beneath a locale are rejected. Fluent syntax errors in private catalogs fail the build.

Each generated locales module contains:

ItemMeaning
DEFAULTen when present; otherwise the first locale in sorted order
CATALOGEmbedded (tag, source) pairs for this namespace
ALL(tag, display name) pairs; names come from a literal language_name message, falling back to the tag
SCOPEA static day_fluent::Catalog containing the namespace, default, and sources
register()Makes this catalog available to qualified script lookups without changing the app locale

The global resource generator emits DEFAULT, CATALOG, and ALL, plus install() instead of SCOPE and register(). Normal app startup uses WindowOptions::locales; the explicit install function remains available for code that manages installation itself.

Combining resource and private catalog generation

Both include macros declare pub mod res. A crate using both generators must place the outputs in separate modules. For example:

// build.rs
fn main() {
    day_build::prebuild_project().expect("day-build: prebuild");
    day_build::generate_locales().expect("localization codegen");
}
// src/lib.rs
day::resources!();

pub mod messages {
    include!(concat!(env!("OUT_DIR"), "/day_locales.rs"));
}

This exposes global resources under res and private translations under messages. The resource generator still scans the same Fluent files as one global catalog. Files with colliding names or different parameter lists that are valid in separate private namespaces cannot also pass that global scan. Apps with reusable components can keep the global root catalog and use private generation in the component crates.

Generated message functions

Generated functions return LocalizedText. Function parameters follow the message’s $variables, sorted by name. Variable names must agree across translations. Arguments accept strings, numbers, or supported signals through IntoFArg. A variable used numerically in any translation requires IntoNumberFArg, which accepts i64, f64, Signal<i64>, and Signal<f64>.

Plural selectors and NUMBER() arguments are numeric. String selectors, such as a selection on $gender, remain string-capable. The generator unions message keys across locales; translation completeness is a separate lint check. Generated documentation prefers the English message when available.

A Fluent attribute such as menu_group.key generates menu_group_key(). Generation fails if another key produces the same Rust name. A locale may omit an attribute and inherit the catalog default’s value; coverage lint requires message keys, not every attribute.

LocalizedText implements the text conversion used by pieces. A text binding tracks both the locale signal and signal arguments. Calling .format() resolves a String immediately; it remains reactive only when the call runs inside a reactive computation.

Runtime implementation

day-build parses Fluent sources and generates the accessors. day-l10n manages Fluent bundles, locale selection, formatting, and the core catalog. day-fluent adds LocalizedText and the tr_in constructor used by private accessors.

A private accessor calls tr_in(&SCOPE, key). Registration parses its catalog on first use and caches the bundles in thread-local runtime state. The static descriptor’s address identifies the catalog; its diagnostic name does not determine typed lookup. Two versions of a crate can therefore keep separate translations even when their script namespace is the same. Reinstalling the global app catalog preserves private caches and the shared locale signal.

The registration path uses ordinary Rust calls on native targets and WebAssembly. It does not require platform initializers or an app-maintained dependency registration list. Catalog sources are embedded in the binary; registration does not read translation files from disk.

Lookup and fallback

For a requested locale, bundle selection tries the exact tag, the tag without a -u-… extension, and then its language subtag. Once it selects a bundle, a missing message or attribute follows the relevant default-catalog fallback below.

LookupResolution order
Private accessorSelected private bundle → that private catalog’s default bundle
Global tr(key)Selected app bundle → app default → selected core bundle → English core

Private lookup never searches the app, core, or another private catalog for a matching key. An unresolved global key renders as ⟨key⟩; a private key renders as ⟨package::key⟩ or ⟨package::file::key⟩. Fallback permits partial translations to render, but a project that requires complete translations must also enforce coverage checks.

Message lookup recognizes these Chinese aliases when the earlier candidates are absent:

Requested tagCatalog tag
zh-Hans, zh-Hans-CN, zh-SGzh-CN
zh-Hant, zh-Hant-TWzh-TW

Launch negotiation checks the app’s registered tags separately. An app shipping zh-CN may need a compatible root-catalog alias, such as zh, to accept the platform’s script-based preference before message lookup runs. Day Games registers that alias using the same source; it keeps one translation file and one store listing per language.

Which language an app opens in

day::launch collects backend locale hints, installs WindowOptions::locales, computes title_fn, and then builds the UI. The app catalog declares supported launch languages; private catalogs do not add languages to that declaration.

If DAY_LOCALE is set, it supplies the launch candidate. Otherwise Day considers the ordered host preferences, including an explicit web ?locale= value before browser preferences. It selects the first candidate the app catalog can serve, or the configured default if none matches. Launch matching accepts exact tags, tags without Unicode extensions, language subtags, and pseudolocales. Core catalogs determine availability only when the app registers no catalog entries.

set_locale from day::prelude changes the shared signal at runtime. It does not persist a preference. An app that saves a selection must restore it through its startup flow. Locale strings passed to set_locale normalize underscores to hyphens.

Core strings the framework provides

The core catalog supplies standard dialog buttons, menu roles, window commands, and settings labels. It ships en, fr, es, de, ja, zh, and ar. Examples include day-ok, day-cancel, day-copy, and day-about-app; the last accepts the app name as $app. Framework code resolves these through the global lookup, so an installed app catalog can override them. Private catalog messages do not override core strings.

Native dialogs and permission UI may also contain text supplied by the operating system. Those strings follow the system’s language rules and may not follow an in-app locale switch.

Formatted values: NUMBER() and DATETIME()

Every Day Fluent bundle registers ICU4X-backed NUMBER() and DATETIME() functions. Plain numeric interpolations also use locale-aware decimal formatting.

amount = { NUMBER($value, minimumFractionDigits: 2) }
percentage = { NUMBER($value, style: "percent") }
saved = Saved { DATETIME($when, dateStyle: "long", timeStyle: "short") }
FunctionSupported behavior
NUMBERGrouping, minimum integer digits, minimum/maximum fraction digits, minimum/maximum significant digits, and decimal or percent style
DATETIMEISO date, time, or date/time strings; numeric Unix seconds as UTC; dateStyle and timeStyle values full, long, medium, short, or none

Decimal formatting defaults to at most three fraction digits unless options request more. Percent formatting multiplies by 100 and adds a localized percent sign. Currency style is not implemented; it renders as decimal and produces a lint finding.

Date/time formatting uses the Gregorian calendar. Civil strings carry no time zone. Numeric timestamps use seconds, not milliseconds. Defaults depend on the input: medium date style and short time style where those parts exist. An unparseable input remains visible as text.

day lint checks function names and options in each catalog. Findings distinguish unknown functions, invalid option names or values, and options that Day does not support.

Numbers outside a message

day::format_decimal(value, fraction_digits) formats a number using the selected locale. day::format_decimal_in(locale, value, fraction_digits) takes an explicit locale. The first tracks the locale signal, so it can update a readout inside a reactive closure:

label(move || day::format_decimal(total.get(), 2))

Grouping, decimal separators, and digits come from locale data. Non-finite values or missing data fall back to Rust formatting.

Sorting: locale-aware collation

day::compare, day::compare_in, and day::sort_localized use ICU4X collation. The first and last track the selected locale. compare_in accepts locale extensions, such as zh-u-co-stroke, for an alternate collation. Sort translated display strings; keep stable IDs unchanged when the display order changes.

Searching: localized match

day::matches_search(text, query) performs a case-insensitive prefix match at word starts. matches_search_in takes an explicit locale. An empty query matches everything, and the start of the text is always a candidate. Multi-word prefixes are supported.

ICU4X segmentation supplies word boundaries for scripts that do not separate words with spaces. Unicode case folding handles cases such as Straße and STRASSE; Turkish and Azerbaijani use Turkic folding. Matching does not remove accents: é and e remain distinct.

Locale data

ICU4X components use compiled locale data independently of the app’s Fluent locale list. Changing that list changes the app’s translations, not the embedded ICU data selection. Unused components can be removed by the linker. Builds that supply custom baked data can use ICU4X_DATA_DIR; the Day CLI does not run ICU data generation as part of app builds.

Keyboard shortcuts

Fluent attributes can provide localized shortcut characters:

menu_group = Group
    .key = g

This generates res::str::menu_group() and res::str::menu_group_key(). A missing translated attribute falls back to the default locale. Modifiers remain command behavior in Rust, and standard menu roles retain platform shortcuts. See menus.

Permission reasons and store metadata

Platform metadata tools read the app root’s catalogs. Keep permission reasons there using permission_<name> keys, including raw platform keys where required. These messages are consumed during platform generation and are exempt from the app’s unused-key lint. See permissions for the key mapping and generated platform files.

Store copy lives in store/<locale>/, separate from UI messages. day store stage maps project locale tags to each store’s spelling and generates fastlane metadata. Website locale configuration lives in website/site.toml; the app site uses store text and localized screenshot metadata. See store listings and DayScript.

Adding and removing locales

day localize list surveys the app root’s Fluent directories, store directories, Xcode knownRegions, and website locale list. day localize add <tags> copies the default Fluent files and updates the other surfaces when present. It translates recognized scaffold messages where a starter translation exists; the remaining messages need translation. Store text is copied verbatim. Existing locale directories are left in place.

day localize remove <tags> removes the corresponding root surfaces. It refuses to remove the default locale. Neither command edits private catalogs in dependency crates or the CI locale matrix. Those changes belong in the app’s translation workflow.

Project tags use a lowercase language, optional titlecase script, and optional uppercase or numeric region, such as fr, zh-Hans, or es-419. Store support is checked separately. Metadata mappings such as zh-CN to Apple’s zh-Hans do not rename the source directory.

Lint coverage and stale translations

day lint --strict fails when findings remain; day localize list is informational. The compiler and generator check typed call sites and parameter consistency. The linter adds coverage and metadata checks:

FindingScope
unknown-key, unused-keyHeuristic source-reference checks for the global app catalog
missing-translationDefault-locale message keys absent from another existing locale
invalid-catalogPrivate catalog generation errors, including invalid syntax and collisions
unknown-function, bad-format-option, unsupported-format-optionFluent formatting calls

Private catalog lint recognizes workspace source roots whose build.rs calls generate_locales(). It checks files separately and does not report unused public accessors. It does not audit arbitrary downloaded dependencies or require every crate to ship the app’s full locale set. Project checks can enforce exact locale, filename, key, and argument parity.

Coverage checks detect missing messages, not outdated wording. Day does not store a source revision or review state for each translation. An unchanged key with changed English text can pass every structural check. Copied text can pass too. Review reference-catalog diffs, resolve translation TODOs, and check the rendered screens. Remove obsolete keys from all locales; extra translated keys are not a reliable signal of a current catalog.

Right-to-left locales

Day chooses layout direction at launch. Its layout engine mirrors horizontal placement, while toolkits configure widget text and internal behavior for the selected direction. Leading and trailing layout values follow that direction. Canvas drawings and other leaf content are not automatically mirrored.

Runtime locale changes update strings but do not recompute the launch-time layout direction. A direction change requires relaunching. Real right-to-left locales, such as Arabic, are used for layout verification; Day does not provide an ar-XB pseudolocale.

Pseudolocale

The -XA suffix accents and expands messages from the locale beneath it. en-XA exercises the English catalog, and fr-XA exercises French. The transformation applies to formatted output, including interpolated values, in global and private catalogs. It is a layout test, not a translation-completeness check.

DayScript catalog keys

A private assertion uses package::key or package::file::key. The catalog must already be registered by an accessor call or by the generated register() function. Bare keys continue to resolve globally. Two registered descriptors with the same script namespace make the qualified lookup ambiguous; it returns an unresolved marker. Typed accessor calls remain isolated by descriptor identity.

Use the same element IDs across locales and pass message arguments through the assertion’s args field. day launch --locales 'en fr ar' --script dayscript/walkthrough.yaml runs the script per locale. A reusable dayapp.yml job takes the locale list in its locales input. The CLI’s locale-add command does not update that input.