# Multilingual architecture

This document owns the localization contract of Gizlang pages: how a developer writes text once, how it is translated, how a request
gets its language, and what that costs. The web stack is owned by [the web internals](web.md) and the page language by
[GHTML](../reference/ghtml.md#9-languages).

| Part | Owner |
| --- | --- |
| Locale data and formatting (CLDR 48.2.3: 445 locales, plurals, numbers, dates, lists, names) | `sdk/core/locale.giz` (`gizlang:locale`), tables from `tools/locale/generate.giz` |
| Messages: extraction, catalogs, compiled variants, checks | `tools/cli/ghtml.giz` (messages), `tools/cli/sources.giz` (catalogOf) |
| The language of a request, addresses, headers, links | `web/i18n.giz`, used by `web/pages.giz` |
| Catalog maintenance | `giz i18n` (`tools/cli/i18n.giz`) |
| Language picker | `web/components/LanguagePicker.ghtml` |

## 1. Principles

- **Write the source language, nothing else.** A page is written in plain English. There are no keys, no `t()` calls and no wrappers: the
  compiler finds the text a person reads. A file that no catalog translates compiles to the same code as before, byte for byte.
- **The source text is the key.** A message is keyed by its text in [Unicode MessageFormat 2](https://www.unicode.org/reports/tr35/tr35-messageFormat.html)
  (`Hello {#b}{$name}{/b}!`). A changed English sentence is a new message; until it is translated it shows in English, and
  `giz i18n` lists the old key as unused. A page never breaks for a missing translation.
- **Translations are code.** Each translated message is compiled into the module of its file, beside the source, with the holes and
  elements of the source. A request parses nothing, looks nothing up and allocates no dictionary. A translation cannot add markup,
  drop a link or name a value the source does not have: the build fails, naming the file, line and catalog.
- **One locale per request, passed explicitly.** The locale of a page is chosen once (the address, the person's choice, the browser) and
  reaches components as a prop. Nothing is global to the process.
- **CLDR, not hand-written rules.** Plural categories, numbers, dates, lists, language names and fallback chains come from CLDR, checked
  against every CLDR plural sample (`tools/locale/conformance.giz`: 7327 checked, 0 wrong).

## 2. Writing pages

A **message** is a run of text, holes and inline elements (`a`, `b`, `strong`, `em`, `code`, `span`, `br` …) between block boundaries:

```html
<p>Hello <b>{user.name}</b>, you have {count} new messages.</p>
```

is the message `Hello {#b}{$name}{/b}, you have {$count} new messages.`

- A hole `{user.name}` is the placeholder `{$name}`: the last name of its member chain; another expression is `{$v1}`. The same expression
  is one placeholder; two different ones with one name are `{$name}` and `{$name1}`.
- An inline element is markup: `{#a}…{/a}`, `{#br/}` when it is empty. Its attributes stay with the source.
- White space at the edges of a message stays as written; runs of white space inside are one space in the key.
- An element alone is no sentence: `<a href="/pricing">Pricing</a>` is the message `Pricing`.
- Not translated: `pre`, `code`, `kbd`, `samp`, `var`, `textarea`, `script`, `style` on their own, anything with `translate="no"`, and
  text with no letter (`·`, `→`, digits, emoji).
- Attributes people read are messages too: `title`, `alt`, `placeholder`, `aria-label`, `aria-description`, `aria-placeholder`,
  `aria-roledescription`, `aria-valuetext`, `abbr`, `label` (option, optgroup, track), `value` of a button input, and `content` of the
  description, Open Graph and Twitter `meta`. On a component, the props named `label`, `title`, `placeholder`, `alt`, `heading`,
  `description`, `hint`, `help`, `caption`, `empty`, `confirm`, `message`, `text`, `subtitle` and `aria-label` are.

A count chooses its sentence with `plural` and a case per CLDR category of English (`one`, `other`) or exact count (`=0`):

```html
<template plural={count}>
  <template case="=0">No messages</template>
  <template case="one">{count} message</template>
  <template case="other">{count} messages</template>
</template>
```

is the message `.input {$count :number} .match $count =0 {{No messages}} one {{{$count} message}} * {{{$count} messages}}`. A translation
names the categories of its own language: Korean `.match $count * {{메시지 {$count}개}}`, Polish adds `few` and `many`. An exact
count wins over a category; `*` takes the rest and is required.

**Where text is translated.** A message is translated where a locale is in scope:

| File | Locale |
| --- | --- |
| A page (route file) | `locale`, the request's (ambient, like `request`); `languages` lists the site's |
| A component | the prop `locale: Locale` (`gizlang:locale` is imported for it); a caller passes `<Card {locale}/>` |
| A file of functions | a function with a parameter `locale: Locale` |

A component without a `locale` prop shows its source text. Numbers and dates are formatted with the same value:
`{locale.integer(n)}`, `{locale.money(cents, "EUR")}`, `{locale.date(t)}`, `{locale.list(names)}`, `{locale.relative(-3, "day")}`.

## 3. Catalogs

The catalogs of a file are `locales/TAG.json` in the nearest folder at or above it (up to the package root): one JSON object of each
message to its translation, `""` for one not yet translated.

```json
{
  "Hello {#b}{$name}{/b}, you have {$count} new messages.": "{#b}{$name}{/b}님, 새 메시지가 {$count}개 있습니다.",
  "Search": "검색"
}
```

- `giz i18n DIR` adds each new message to every catalog with `""`, removes the keys that no file shows, and prints how much of each
  catalog is translated. `--add ko` starts a catalog. `--check` (for CI) changes nothing and fails when a catalog is not up to date or a
  translation does not fit.
- A translation is MessageFormat 2: `{$name}` for a placeholder, `{#a}…{/a}` and `{#br/}` for elements, `\{`, `\}` and `\\` for the signs.
  Each element of the source appears once, opened and closed in order; a placeholder is one the source has. Otherwise the build fails:
  `index.ghtml:6: locales/ko.json, "Read the {#a}terms{/a} now.": the element a of the source is missing.`
- A catalog `pt` serves `pt-BR` and `pt-PT` too: a locale takes the first catalog of its CLDR chain (`zh-TW` → `zh-Hant-TW`, `zh-Hant`).
- The module of a file is made again from its text and its catalogs at each build, so a changed catalog changes the page at the next build.

## 4. Compiled code

```html
<p title="Count">You have <b>{n}</b> items.</p>
```

with a Korean catalog compiles to

```
const _k = locale.slot(["ko"]);
… <p title={_k === 1 ? "개수" : "Count"}>{_k === 1 ? <><b>{n}</b>{"개"}</> : <>You have <b>{n}</b> items.</>}</p>
```

- `_k` is the place of the request's locale among the file's catalogs (`Locale.slot`), computed once per page or component call.
- A translation is one line, written where its source starts: the module keeps the lines of the file, so errors name the line of the
  `.ghtml` file.
- Holes are the source's own expressions, type-checked once; a translation only reorders them.

## 5. The language of a request

A site of pages speaks English and the language of each catalog beside its pages (the `.routes` module calls
`_P.speaking(site, "en", tags)`). `web/i18n.giz` answers each request in one of them:

1. the address: `/ko/pricing` is Korean, `/pricing` English (one address per language, which search engines index and people share);
2. the person's choice: `?hl=ko` sets the cookie `locale` for a year and redirects (303) to the page's address in that language;
3. the browser: Accept-Language, matched by RFC 4647 over the CLDR chains (`ko-KR` takes `ko`);
4. English.

- A browser's navigation (`Sec-Fetch-Mode: navigate`) to an English address while its choice or languages say otherwise is sent to
  the prefixed address (307). A crawler, which sends no such header, gets the address's own language. `//host` is never a redirect.
- An application behind a sign-in, whose addresses are not shared, uses one address for every language: `_P.byCookie(site)`.
- Each answer has `Content-Language`; an unprefixed one has `Vary: Accept-Language, Cookie` too. `<html lang dir>` follows the locale
  (`dir="rtl"` for Arabic, Hebrew, Persian, Urdu …), and the kernel updates both on client navigation.
- The head lists `<link rel="alternate" hreflang>` for each language and `x-default`.
- Root-relative `href` and `action` links of a page, and of its live patches, point to the same language (`/pricing` in a Korean page is
  `/ko/pricing`). A link with `hreflang` (the picker's), a file (`/logo.svg`) and `/_giz/…` stay.
- `<LanguagePicker {locale} {languages} path={request.path}/>` (gizweb) lists each language in itself (한국어, English, 日本語) with
  `lang` and `hreflang`, marking the current one with `aria-current`.

## 6. Cost

Measured 2026-10-19 on backup (release build, 20 000 requests each, noisy host): the example site `tests/web/i18n` (two pages, one
component, a plural, a picker; English, Japanese, Korean, Polish).

| Request | Time | Bytes |
| --- | --- | --- |
| Same pages, no catalogs | 7.8 µs | 872 |
| English page of the four-language site | 10.4 µs | 1322 |
| Korean page | 12.1 µs | 1384 |
| Japanese page (one message translated) | 12.2 µs | 1343 |

The extra time is the site's own extra work, not the messages: the picker's three more links, four `hreflang` links (0.34 µs), the
negotiation (0.23 µs for `/`, 0.6 µs for `/ko`) and link localization (0.44 µs). A translated message costs one integer comparison.
`L.locale("ko")` takes 3.3 µs and Accept-Language matching 3.0 µs (binary search over the sorted CLDR tables; it was 16 µs).

## 7. Limits

- Mixed attributes (`title="Hi {name}"`) are not messages.
- Mail, notifications and queued work have no locale of their own.

## Primary specifications

- [BCP 47 tags](https://www.rfc-editor.org/info/rfc5646/), [language matching (RFC 4647)](https://www.rfc-editor.org/info/rfc4647/)
- [Unicode locale data (UTS #35)](https://www.unicode.org/reports/tr35/) and [MessageFormat 2](https://www.unicode.org/reports/tr35/tr35-messageFormat.html)
- [CLDR plural rules](https://cldr.unicode.org/index/cldr-spec/plural-rules)
