GizStack
View .mdOpen in ChatGPTOpen in Claude

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.

PartOwner
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, checkstools/cli/ghtml.giz (messages), tools/cli/sources.giz (catalogOf)
The language of a request, addresses, headers, linksweb/i18n.giz, used by web/pages.giz
Catalog maintenancegiz i18n (tools/cli/i18n.giz)
Language pickerweb/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, 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:

FileLocale
A page (route file)locale, the request's (ambient, like request); languages lists the site's
A componentthe prop locale: Locale (gizlang:locale is imported for it); a caller passes <Card {locale}/>
A file of functionsa 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).

RequestTimeBytes
Same pages, no catalogs7.8 µs872
English page of the four-language site10.4 µs1322
Korean page12.1 µs1384
Japanese page (one message translated)12.2 µs1343

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