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 and the page language by GHTML.
| 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
(
Hello {#b}{$name}{/b}!). A changed English sentence is a new message; until it is translated it shows in English, andgiz i18nlists 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:
<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 messagePricing. - Not translated:
pre,code,kbd,samp,var,textarea,script,styleon their own, anything withtranslate="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),valueof a button input, andcontentof the description, Open Graph and Twittermeta. On a component, the props namedlabel,title,placeholder,alt,heading,description,hint,help,caption,empty,confirm,message,text,subtitleandaria-labelare.
A count chooses its sentence with plural and a case per CLDR category of English (one, other) or exact count (=0):
<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.
{
"Hello {#b}{$name}{/b}, you have {$count} new messages.": "{#b}{$name}{/b}님, 새 메시지가 {$count}개 있습니다.",
"Search": "검색"
}giz i18n DIRadds each new message to every catalog with"", removes the keys that no file shows, and prints how much of each catalog is translated.--add kostarts 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
ptservespt-BRandpt-PTtoo: 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
<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>_kis 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
.ghtmlfile. - 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:
- the address:
/ko/pricingis Korean,/pricingEnglish (one address per language, which search engines index and people share); - the person's choice:
?hl=kosets the cookielocalefor a year and redirects (303) to the page's address in that language; - the browser: Accept-Language, matched by RFC 4647 over the CLDR chains (
ko-KRtakesko); - 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.//hostis 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 hasVary: Accept-Language, Cookietoo.<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 andx-default. - Root-relative
hrefandactionlinks of a page, and of its live patches, point to the same language (/pricingin a Korean page is/ko/pricing). A link withhreflang(the picker's), a file (/logo.svg) and/_giz/…stay. <LanguagePicker {locale} {languages} path={request.path}/>(gizweb) lists each language in itself (한국어, English, 日本語) withlangandhreflang, marking the current one witharia-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.