# Locales

A locale is a language as a place writes it, such as British English
(`Locale.en_gb`) or the French of France (`Locale.fr_fr`). Your app lists
the locales it supports, once, in `app.jadpo`. Formatting and translations
accept only those, and writing any other is a compile error. The server's
own locale is never used.

```jadpo
application {}

locales {
    default: Locale.en_gb
    supported: [Locale.en_gb, Locale.en_us, Locale.fr_fr]
}
```

`locales { }` is a declaration with no name, like `config { }`. It takes
`default:`, the locale used when no other is chosen, and `supported:`, the
list of locales the app supports, each once. Each entry is written
`Locale.<tag>`, the tag in lower case with `_` for `-` (`Locale.zh_hans` is
zh-Hans).

A locale the list doesn't have is `type.locale_unsupported` when it is
written out where text is formatted, or as the `default:` or `test:` of a
`Locale` setting. A project that formats text and declares no locales is
`type.temporal_locales_required`.

There is no `unsupported:` setting. A request field typed `Locale` is
decoded like any enum, so a tag the app doesn't support is an
`invalid_value` failure, and the OpenAPI document lists only the supported
tags. A setting typed `Locale` is read the same way when the app starts. To
choose a language from a browser's `Accept-Language` header, call a library
function that falls back to `default:`
([Choosing a locale from a request](locales.md#choosing-a-locale-from-a-request)).

## Locales still being translated: `in_progress:`

`in_progress:` is a third, optional option, written once: a list of the
supported locales whose translations aren't finished yet. Every other
supported locale must be complete. Only a locale listed here falls back
where wording is missing, with no error, because listing it is how you say
so ([Translations as messages](translations-as-messages.md)). A locale listed
there but not in `supported:` is `type.locale_in_progress_unsupported`, and
the default can't be listed (`type.locale_in_progress_default`).

```jadpo
application {}

locales {
    default: Locale.en_gb
    supported: [Locale.en_gb, Locale.en_us, Locale.fr_fr, Locale.pl_pl]
    in_progress: [Locale.pl_pl]
}
```

## A locale the app doesn't support

Text for people is only ever written in a supported locale. A `Locale` the
list doesn't have can still reach `text.format`, `temporal.format_relative`
or a message while the app runs: one saved with a record before
`supported:` changed, or one written out anywhere but `locale:`. It is
written in the supported locale a browser asking for it would get
([Choosing a locale from a request](locales.md#choosing-a-locale-from-a-request)): a
supported locale of its language, the one CLDR gives as the language's
likely region first, else `default:`.

A list `list.sort_by` orders in such a locale is put in that same supported
locale's alphabetical order. So `jadpo test` and a deployable, which carries
only its supported locales' sorting rules, give the same order. Sorting by
a locale needs `locales { }`, as formatting does
(`type.temporal_locales_required`), so a deployable never carries sorting
rules for a locale the app doesn't support. Changing case isn't text for
people in a language, and takes any `Locale`
([Text](text.md)).

```jadpo
application {}

locales {
    default: Locale.en_gb
    supported: [Locale.en_gb, Locale.fr_fr]
}
```

```jadpo
/// When an order was placed.
type OrderedAt = Instant {}
```

```jadpo
/// When an order was placed, shown to its customer.
type PlacedText = Output {
    placed: PresentationText
}
```

```jadpo
use shop/types { OrderedAt, PlacedText }

/// The placing, in the locale saved with the customer's order.
action show_placed(ordered_at: OrderedAt, saved_locale: Locale) -> PlacedText {
    return PlacedText {
        placed: text.format(
            ordered_at,
            zone: Zone.europe_london,
            locale: saved_locale,
            format: TimeFormat.date_long
        )
    }
}
```

```jadpo
test "a locale the application doesn't support is written in one it does" {
    assert show_placed("2026-09-27T13:30:05Z", Locale.fr_fr).placed == "27 septembre 2026"
    assert show_placed("2026-09-27T13:30:05Z", Locale.fr_ca).placed == "27 septembre 2026"
    assert show_placed("2026-09-27T13:30:05Z", Locale.de_de).placed == "27 September 2026"
}
```

A deployable carries the locale data of its supported locales only.
`jadpo build` takes from the CLDR data compiled into Jadpo the entries the
supported locales reach (the locale, then each locale it falls back to,
down to the root), their sorting rules among them, and the data that is the
same in every locale, and compiles the deployable with that. `jadpo test`
and `jadpo dev` keep every locale, and write the same text.

## Text direction

Every locale knows which way its text runs. `locale.direction(value)` gives
a value of the built-in enum `TextDirection`, `.left_to_right` or
`.right_to_left`, from the script CLDR says the locale most likely uses:
`Locale.ar_eg` and `Locale.he_il` read right to left, `Locale.en_gb` left to
right. Jadpo's wording of the two values is the HTML values, `ltr` and
`rtl`, so the email layout's `<html dir="{{ locale.direction }}">` writes
one of them.

```jadpo
application {}

locales {
    default: Locale.en_gb
    supported: [Locale.en_gb, Locale.ar_eg]
}
```

```jadpo
test "a locale knows which way its text runs" {
    assert locale.direction(Locale.ar_eg) == TextDirection.right_to_left
    assert locale.direction(Locale.en_gb) == TextDirection.left_to_right
}
```

## Choosing a locale from a request

A browser says which languages its reader wants in its `Accept-Language`
header. `locale.from_accept_language(header)` gives the `Locale` it asks for,
out of the locales your app supports, and `default:` when the header names
none of them. The call never fails, and it is the only way a request chooses
a locale that isn't written in its input.

`header` is `Text?`, which may hold no value. An input field read with
`header: "Accept-Language"` and `optional: true`
([Headers](one-input.md#headers)) is passed straight in, whether the request
sent the header or not:

```jadpo
type PlaceRequest = Input {
    ordered_at: OrderedAt
    languages_header: BrowserLanguages {
        header: "Accept-Language"
        optional: true
    }
}
```

```jadpo
application {}

locales {
    default: Locale.en_gb
    supported: [Locale.en_gb, Locale.en_us, Locale.fr_fr]
}
```

```jadpo
/// What a browser says its reader's languages are.
type BrowserLanguages = Text {
    max_length: 2000
}
```

```jadpo
/// When an order was placed.
type OrderedAt = Instant {}
```

```jadpo
/// An order's placing, shown to its customer.
type PlacedText = Output {
    reply_locale: Locale
    placed: PresentationText
}
```

```jadpo
use shop/types { BrowserLanguages, OrderedAt, PlacedText }

/// The placing, in the language the customer's browser asks for.
action show_placed(ordered_at: OrderedAt, languages_header: BrowserLanguages?) -> PlacedText {
    var reader_pick: Locale = locale.from_accept_language(languages_header)
    return PlacedText {
        reply_locale: reader_pick
        placed: text.format(
            ordered_at,
            zone: Zone.europe_london,
            locale: reader_pick,
            format: TimeFormat.date_long
        )
    }
}
```

```jadpo
test "the browser's languages choose the locale" {
    assert show_placed("2026-09-27T13:30:05Z", "fr-FR").reply_locale == Locale.fr_fr
    assert show_placed("2026-09-27T13:30:05Z", "fr-CA").reply_locale == Locale.fr_fr
    assert show_placed("2026-09-27T13:30:05Z", "en").reply_locale == Locale.en_us
    assert show_placed("2026-09-27T13:30:05Z", "en-GB;q=0.3, fr;q=0.9").reply_locale == Locale.fr_fr
    assert show_placed(
        "2026-09-27T13:30:05Z",
        "fr-FR;q=0, en-US;q=0.1"
    ).reply_locale == Locale.en_us
    assert show_placed("2026-09-27T13:30:05Z", "de-DE, ja;q=0.8").reply_locale == Locale.en_gb
    assert show_placed("2026-09-27T13:30:05Z", "fr-FR;q=nonsense").reply_locale == Locale.en_gb
    assert show_placed("2026-09-27T13:30:05Z", none).reply_locale == Locale.en_gb
    assert show_placed("2026-09-27T13:30:05Z", "fr-FR").placed == "27 septembre 2026"
}
```

The rules are fixed, so every platform gives the same locale for the same
header and the same `locales { }`:

- **Reading the header.** As RFC 9110 reads `Accept-Language`: a list split
  at commas, each entry a language range (`fr-CA`, `en`) with an optional
  weight, `;q=` and a number from 0 to 1 with up to three decimals. Spaces
  and tabs around entries and the `;` are ignored, an empty entry is
  nothing, and the range is read in any case, `_` as `-` (a client that
  writes `en_GB` means it). An entry that does not parse (a weight like
  `1.5` or `abc`, a range with an empty part or a part over eight
  characters, anything after the weight) is skipped and the others are
  still read. A weight of `0` means "not acceptable", so that tag is
  skipped too, and `*`, which names no language, is ignored.
- **Taking the tags.** By weight, the heaviest first; tags of equal weight
  keep the order the header gives them (an entry with no weight has `1`).
  The first tag that matches a supported locale decides, so a lighter tag
  that matches is chosen over a heavier one that does not.
- **Matching a tag.** Against `supported:` only, never against every
  `Locale`. A supported locale with exactly the tag wins, whatever the case
  (`fr-FR` is `Locale.fr_fr`). Otherwise a supported locale of the same
  language answers, so `fr-CA` gets `fr_fr` when French is supported only
  as `fr_fr`. Where several are of that language, the one CLDR gives as the
  language's likely region is chosen (`en` means `en-US`, `pt` means
  `pt-BR`), and if none of them is that one, the first in the order
  `supported:` lists them. Only the language is compared, never the
  script, so list a locale a header should reach exactly (`zh-TW` is
  `Locale.zh_tw`); otherwise it is answered by another locale of its
  language.
- **Falling back.** With no tag matching, an empty header or `none`, the
  result is `default:`. It is never the server's own locale, and never a
  locale that is not supported.
- **A pure function.** The same header and `locales { }` give the same
  locale in the interpreter, a native deployable and WebAssembly. It reads
  no clock, store or request, so it may be called in any function, action
  or test. A query computes nothing, so it cannot call it
  (`query.value_unsupported`): choose the locale in the action and pass it
  in.
- **The call is checked as a standard-library call is.** `header` is
  always given, by position or as `header:`, and takes a `Text`, a type
  built on `Text`, `none`, or any of these that may be `none`
  (`type.argument_count`, `type.named_argument_unknown`, `type.mismatch`).
  A project with no `locales { }` has nothing to choose among, which is
  `type.temporal_locales_required`, as it is for formatting.
- **The name `locale` is reserved.** It is a standard-library namespace
  now, so a parameter or variable cannot be called `locale`
  (`semantic.standard_namespace_reserved`); the examples on this page
  call theirs `reader_locale`. A field or a named argument called `locale`
  (`locale:`) is a different thing and stays.
