# Internationalization (i18n)

Build multi-language sites with language-aware routing and localized content.

## Overview

Uniweb uses a **Component Content Architecture (CCA)** where content arrives to components already localized. There is no runtime translation lookup—each language is a complete static build.

Key characteristics:

- **Language-prefixed URLs**: `/`, `/es/`, `/fr/` for different languages
- **Build-time translation**: Translations are merged during build, not runtime
- **Full page reload for switching**: Navigating to `/es/about` loads Spanish content
- **SEO-friendly**: Each language generates separate static HTML with proper `hreflang` tags

---

## Quick Start

### 1. Configure Languages

```yaml
# site.yml
defaultLanguage: en
languages: [en, es, fr]
```

### 2. Build Your Site

```bash
uniweb build
```

### 3. Extract Translatable Strings

```bash
uniweb i18n extract
```

This scans your built content and generates `locales/manifest.json` with all translatable strings keyed by content hash.

### 4. Initialize Language Files

```bash
uniweb i18n init es fr
```

Creates `locales/es.json` and `locales/fr.json` pre-populated with all manifest keys. Values default to source text so translators can see what to translate.

### 5. Translate

Edit the generated language files, replacing source text with translations:

```json
{
  "a1b2c3d4": "Bienvenido a Nuestra Empresa",
  "e5f6g7h8": "Aprende Más"
}
```

### 6. Build with Translations

```bash
uniweb build
```

The build merges translations and generates language-specific output.

---

## Configuration

### Basic Setup

```yaml
# site.yml
defaultLanguage: en
languages: [en, es, fr]
```

The default language has no URL prefix (`/about`), while other languages get prefixed (`/es/about`, `/fr/about`).

### Language Names

A language is named by its code — list codes, not labels:

```yaml
defaultLanguage: en
languages: [en, es, fr]
```

Language switchers show each language in its own name: `English`, `Español`, `Français`. Common languages come from a built-in table, and any other code is named by the platform (`ca` is `Català`, `eu` is `Euskara`), so a site never writes a label. The older `{ code, label }` entries are an error: a label cannot travel when the site is pushed to a backend, so a site that used them showed different names depending on where it was served.

### Auto-Discovery

```yaml
defaultLanguage: en
languages: '*'
```

Automatically discovers languages from the `locales/` folder. Any `.json` file in the locales directory (other than `manifest.json` and `_memory.json`) is treated as a language.

### The default language belongs in `languages`

List it, alongside the others:

```yaml
defaultLanguage: en
languages: [en, fr]
```

Its pages are built at the **site root**, not under `dist/en/` — the root tree *is* the default language, and `hreflang="x-default"` already points there. So listing it changes what the site *declares*, never what it *emits*: a language switcher needs it as a choice, and a site that never names it can end up with no switcher at all.

A URL that carries the default language's code anyway — `/en/about` — names the same page as `/about`, and a visitor who follows one is sent there. A host that answers with a status sends a permanent redirect (`301`).

> **Declare it even if translations are auto-discovered.** The build finds locales on disk, but a page's links are resolved at render from what the site declares. A site with `locales/fr.json` and no `languages:` builds a complete French tree whose every internal link points back into the default-language tree — the pages exist and nothing reaches them. Recent versions stamp the resolved set onto the build output so this can't happen silently; declaring it is still the clearer form. A `uniweb pull` writes the list your site has, so a site that declares none gets one.

### Custom Locales Directory

```yaml
i18n:
  localesDir: translations   # Default: locales
```

### Draft Languages

Adding a language to `languages` makes it part of your working set — but a half-translated language shouldn't ship. `publishLanguages` declares which declared languages a published build includes:

```yaml
defaultLanguage: en
languages: [en, es, fr]      # working set — all previewable in dev
publishLanguages: [en, es]   # fr is a draft: built in dev, excluded from production
```

When the field is absent, every declared language is published — existing sites are unaffected. With the field present:

- Production builds skip draft locales entirely: no `dist/fr/` output, no language-switcher entry, no sitemap or `hreflang` references.
- `uniweb dev` renders drafts normally — the list only affects published output.
- The default language must be in the list; an empty or contradictory list fails the build.
- All `uniweb i18n` commands (extract, generate, status) keep covering the full declared set, so translation work on drafts proceeds as usual.
- A code left in `publishLanguages` after its language is removed from `languages` is harmless — it warns at build and re-activates if the language is declared again.

When the translation is ready, add the language to `publishLanguages` and rebuild.

---

## How Content Arrives to Components

**Components receive content already translated.** When a user visits `/es/about`, the component receives Spanish content in `content.title`, `content.paragraphs`, etc.—there's no translation function to call.

```jsx
// Component receives localized content directly
function Hero({ content }) {
  // content.title is already in the user's language
  // No t() function, no translation lookup
  return (
    <section>
      <h1>{content.title}</h1>
      {content.paragraphs.map((p, i) => <p key={i}>{p}</p>)}
    </section>
  )
}
```

This is the **Component Content Architecture (CCA)**—content is resolved before it reaches components.

---

## Generated Routes

For a site with pages `home` and `about`:

| Page | English (default) | Spanish | French |
| --- | --- | --- | --- |
| Home | `/` | `/es/` | `/fr/` |
| About | `/about` | `/es/about` | `/fr/about` |

The default language never has a prefix. Other languages always have their code as a prefix.

---

## Localized URL Segments

By default the path after the language prefix matches the default language (`/about` → `/es/about`). To give the path a **native segment** per language, add `slug:` to the page's `page.yml`:

```yaml
# pages/about/page.yml
slug:
  es: acerca-de
  fr: a-propos
```

| Page | English (default) | Spanish | French |
| --- | --- | --- | --- |
| About | `/about` | `/es/acerca-de` | `/fr/a-propos` |

- The folder name stays the page's stable canonical route (default language + internal `page:` links); list only the non-default languages.
- Each value is a single path segment (no slashes). Nested folders **compose automatically** — localize a parent (`/blog` → `/blogue`) and its children follow (`/blog/post` → `/blogue/post`).
- Localized segments flow through navigation, `website.getLocaleUrl()` (the language switcher below), and the `hreflang` tags.
- One URL per page per language: the default language's segment under another prefix — `/fr/about`, where French shows `/fr/a-propos` — sends the visitor to `/fr/a-propos`. A host that answers with a status sends a permanent redirect (`301`), as for `/en/about`.

See [Page Configuration → Localized URLs](../reference/page-configuration.md#localized-urls) for the full reference.

---

## Language Switcher

### Building a Switcher

```jsx
import { useWebsite, Link, getLocaleLabel } from '@uniweb/kit'

function LanguageSwitcher() {
  const { website } = useWebsite()

  // Only show if site has multiple languages
  if (!website.hasMultipleLocales()) {
    return null
  }

  const active = website.getActiveLocale()

  return (
    <nav>
      {website.getLocales().map(locale => (
        <Link
          reload
          key={locale.code}
          href={website.getLocaleUrl(locale.code)}
          className={locale.code === active ? 'active' : ''}
        >
          {getLocaleLabel(locale)}
        </Link>
      ))}
    </nav>
  )
}
```

> **Use `<Link reload>` rather than a plain `<a>` or `window.location.href`.** `getLocaleUrl()` returns a **root-relative path that does not include the deployment base path**. On a site deployed under a subdirectory (`base: /docs/` in `site.yml`), assigning it directly to an `href` or to `window.location` navigates outside the site. `<Link reload>` prepends `website.basePath` and renders a plain `<a>`, giving the full page load a language switch needs — a normal SPA `<Link>` would not, because the other language is a separate build.
>
> If you need a `<select>` instead of links, prepend the base path yourself: `window.location.href = website.basePath.replace(/\/$/, '') + website.getLocaleUrl(code)`.

### Website Locale API

```js
import { useWebsite } from '@uniweb/kit'

function MyComponent() {
  const { website } = useWebsite()

  // Check if site is multilingual
  website.hasMultipleLocales()  // boolean

  // Get all languages
  website.getLocales()
  // [{ code: 'en', label: 'English', isDefault: true }, ...]

  // Get current language
  website.getActiveLocale()  // 'en'

  // Get URL for switching languages (from current page)
  website.getLocaleUrl('es')  // '/es/about' (if currently on /about)
}
```

### Display Names Utility

The name a switcher shows for a code:

```jsx
import { getLocaleLabel, LOCALE_DISPLAY_NAMES } from '@uniweb/kit'

getLocaleLabel('es')  // 'Español' — the built-in table
getLocaleLabel('ca')  // 'Català' — the language's own name, where the platform knows it
getLocaleLabel('xx')  // 'XX' — the code, when nothing names it

// The built-in names
console.log(LOCALE_DISPLAY_NAMES.fr)  // 'Français'
```

---

## Translation Workflow

Uniweb uses a hash-based translation system for managing translations at scale.

### Extract Strings

```bash
uniweb i18n extract
```

Generates `locales/manifest.json` with all translatable content:

```json
{
  "version": "1.0",
  "defaultLanguage": "en",
  "extracted": "2025-01-29T...",
  "units": {
    "a1b2c3d4": {
      "source": "Welcome to Our Company",
      "field": "title",
      "contexts": [{ "page": "/about", "section": "intro" }]
    },
    "32476724": {
      "source": "That's controlled by your role. See Roles and permissions.",
      "markup": "That's controlled by your role. See [Roles and permissions](page:docs/roles).",
      "field": "paragraph.12",
      "contexts": [{ "page": "/docs/reference/faq", "section": "body" }]
    }
  }
}
```

One unit per block element — a heading, a paragraph, a list item — never a fragment of one, and never two joined. A `\n` inside a `source` is a line break *within* one paragraph.

**`source` is the key; `markup` is the prompt.** The hash is computed over `source`, the element's flattened plain text, so adding a link or bolding a phrase does **not** move the hash and does not orphan the existing translation. `markup` is that same element as inline Markdown, and appears only when the element carries formatting the plain text has lost. It is what `uniweb i18n init` seeds a language file with, and what a translator should work from — except where `markupVaries` says the string's places differ in formatting, when `init` seeds its plain words (see *The same words in several places*, below).

The `contexts` array shows where the string appears, and `field` indicates the content type (title, paragraph, link label, etc.).

> **Why both.** A translation value is parsed as inline Markdown at merge time, so the value channel carries links and marks losslessly. Before `markup` existed, the *source* channel did not: a translator was shown the flattened text and could only produce flattened text. A fully translated site would report 100% coverage and render with every inline link gone.

**Options:**

- `--verbose` — Show extracted strings in output
- `--records-only` — Extract only record content (skip pages)
- `--no-records` — Skip records (pages only)

### Initialize Language Files

```bash
uniweb i18n init es fr
```

Creates starter language files pre-populated with all manifest keys. By default, values are set to the source text so translators can see what needs translating.

**Behavior:**

- **File doesn't exist**: Creates it with all manifest keys
- **File exists** (no `--force`): Merges — adds only missing keys, preserves existing translations
- **File exists with `--force`**: Overwrites entirely

**Options:**

- `--empty` — Use empty strings instead of source text as placeholder values
- `--force` — Overwrite existing files entirely

If no locale codes are specified, initializes all languages configured in `site.yml`.

```bash
# Create files with source text as placeholders
uniweb i18n init es fr

# Create files with empty values (useful for translation tools)
uniweb i18n init --empty

# Overwrite existing files from scratch
uniweb i18n init es --force
```

### Translate

Edit language files, replacing source text (or empty strings) with translations:

```json
{
  "a1b2c3d4": "Bienvenido a Nuestra Empresa",
  "e5f6g7h8": "Aprende Más"
}
```

**A value is inline Markdown.** At build time the merge parses it and replaces the element's inline content with the result — it does not re-apply the source's marks (buttons aside, below), and there is no placeholder or tag convention. So links, bold and emphasis in a sentence have to be written into the translation:

```json
{
  "32476724": "Cela dépend de votre rôle. Voir [Rôles et permissions](page:docs/roles)."
}
```

A plain-text value for such a sentence produces a plain-text paragraph. `page:` references, root-relative paths and full URLs are all carried through verbatim as hrefs.

**An element made only of links is the exception** — a button, or buttons on consecutive lines (one paragraph, so one string). A value with no link of its own is their labels: the whole value for one link, a line each for several. Each keeps its source link, target and options (`{role=primary icon=…}`) included:

```json
{
  "12133946": "Créer un groupe",
  "583e9a06": "Soutenez notre travail\nEn savoir plus"
}
```

A value that writes a link is taken as written, so a translation can still re-target a button. Labels that don't match the links one per line are taken as written too, and lose their links. A `uniweb pull` writes a button back the same way: its labels, or the link written out where this language sends it somewhere else.

**The same words in several places are one string.** A heading that says *Faculty of Science* and a link that says `[Faculty of Science](/)` share one translation. When a string's places differ like this, the manifest marks it `"markupVaries": true` and `uniweb i18n init` starts it with its plain words. Translate it with the words alone: the heading takes them as they are, and the link — like a button — takes them as its label and keeps its target. A link you write goes to every place the string appears, so write one only when each place should have it; to give one place its own wording, use an override (below). A page title or a navigation label is plain text, and takes a translation's words without its formatting.

`uniweb i18n status` reports the entries where this has been lost:

```
Loses markup: 12  (links/bold in the source, plain in the translation)
```

⚠️ Coverage cannot see this class — a flattened translation still counts as translated — so check that line as well as the percentage.

For strings that appear in multiple contexts and need different translations, use the override format:

```json
{
  "e5f6g7h8": {
    "default": "Aprende Más",
    "overrides": {
      "/pricing:cta": "Ver Precios"
    }
  }
}
```

Override keys use the format `{page}:{section}`.

**Tips:**

- Use `uniweb i18n status --missing --json` to export untranslated strings for translation tools
- The manifest's `contexts` array helps translators understand where each string appears
- Group your translation effort by page using `uniweb i18n status --missing --by-page`

### Build

```bash
uniweb build
```

The build merges translations and generates one `site-content.json` per language:

```
dist/
├── index.html              # English (default)
├── site-content.json       # English content
├── es/
│   ├── index.html          # Spanish
│   └── site-content.json   # Spanish content
└── fr/
    ├── index.html          # French
    └── site-content.json   # French content
```

### Sync Changes

After content updates, sync the manifest to detect additions, removals, and changes:

```bash
uniweb i18n sync
```

Reports what changed (new strings, removed strings, modified strings). Use `--dry-run` to preview without writing changes.

It also reports **remarked** strings — an element whose words are unchanged but whose inline Markdown is not, typically an author adding a link to an existing sentence:

```
  ✎ 3 strings kept their words but changed inline markup (links/bold)
    Existing translations still count as valid — update them or the markup is lost.
```

The hash cannot detect this on its own — that is the point of keying on the flattened text — so without this line an added link would silently never reach any translation.

---

## Translation Status and Auditing

### Status

Check translation coverage for all languages or a specific one:

```bash
uniweb i18n status            # All languages
uniweb i18n status es         # Spanish only
uniweb i18n status --json     # Machine-readable output
```

**Additional options:**

- `--missing` — List all untranslated strings instead of just a summary
- `--missing --by-page` — Group missing strings by page
- `--missing --json` — Export missing strings as JSON (useful for translation tools or AI translation)
- `--freeform` — Show free-form translation status (staleness, orphans)

### Audit

Find stale translations (hashes no longer in manifest) and missing ones:

```bash
uniweb i18n audit             # Show stale and missing entries
uniweb i18n audit es          # Audit Spanish only
uniweb i18n audit --clean     # Remove stale entries from language files
uniweb i18n audit --verbose   # Show detailed output
```

---

## Free-Form Translations

For sections that need complete content replacement (different structure, images, or layout per language) rather than string-by-string translation, use free-form translations.

### Overview

Free-form translations replace an entire section's content with a language-specific markdown file. This is useful for:

- Content that needs different structure per language
- Sections with language-specific images or media
- Marketing copy that should be rewritten, not translated

### File Structure

```
locales/
├── manifest.json
├── es.json
├── freeform/
│   └── es/
│       ├── .manifest.json                  # Staleness tracking (auto-managed)
│       ├── pages/about/hero.md             # By page route
│       ├── page-ids/installation/intro.md  # By page ID
│       └── records/article/getting-started.md
```

### Initialize a Free-Form Translation

```bash
uniweb i18n init-freeform es pages/about hero
uniweb i18n init-freeform es page-ids/installation intro
uniweb i18n init-freeform es records/article getting-started
```

Creates a markdown file pre-populated with the source content, ready for translation. Also records a source hash for staleness detection.

### Staleness Detection

When the source content changes, free-form translations become stale. Check status with:

```bash
uniweb i18n status --freeform
```

The free-form commands read your site's source files, so there's no need to build first. A record's translation is listed as *not checked*: nothing compares it with its record yet.

After reviewing changes, update the recorded hash:

```bash
uniweb i18n update-hash es pages/about hero       # Specific section
uniweb i18n update-hash es --all-stale             # All stale at once
```

### Move and Rename

When pages are reorganized:

```bash
uniweb i18n move pages/docs/setup pages/getting-started
uniweb i18n rename pages/about hero welcome
```

These commands update all languages and their manifests.

### Prune Orphaned Translations

Remove free-form page translations whose section no longer exists — judged by the same paths the site reads translations from, so a translation a page still renders is never removed:

```bash
uniweb i18n prune --freeform --dry-run   # Preview what would be removed
uniweb i18n prune --freeform             # Remove orphaned files
```

Record translations (`records/…`) are never pruned. Neither are the translations of a page or section marked `hidden: true`: it's a draft, left out of the published site but still there.

---

## Records i18n

Record data is translated alongside page content by default. The `extract` command processes both pages and all record JSON — whether generated from `records/` or hand-written.

### Extract Record Strings

```bash
uniweb i18n extract                      # Pages + records (default)
uniweb i18n extract --records-only       # Records only
uniweb i18n extract --no-records         # Pages only
uniweb i18n status --records-only        # Coverage of the record translations
uniweb i18n audit --records-only         # Stale and missing record translations
```

Record strings are stored in a separate manifest at `locales/records/manifest.json`, keyed by the record (`std/article/habitat-protection`) rather than by any query that returns it.

A record whose query has a [data schema](./data-schemas.md) is translated as its schema says: its text and rich-text fields are, and a field that is an enum, a URL or an email, a number, a date, a file, a reference, or marked `translatable: false` is not — the same fields a push to a backend carries in each language. A referenced record's card, shown inside another record, is translated as that record is. An excerpt the build derives from a record's body is derived again in each language from the translated body, so it is never a string to translate. A record with no data schema is read by a heuristic, or by a companion `.schema.js` file.

A tagged data block follows the same rule when its section's component declares the shape of the block's key in `meta.js` `data:` — a data schema (`'@/member'`) or an inline field map. Its text fields are translated and nothing else:

```js
// meta.js — the logos' names are text; a style names one of the component's looks
data: { logos: { name: 'string', style: { type: 'string', enum: ['display-black', 'serif-italic'] } } }
```

A key declared with no shape (`logos: {}`) leaves its blocks to a heuristic. It skips fields such as `href`, `icon` and `slug` and values such as URLs and colors, and offers every other string — a `style: display-black` included, which a translated file would turn into a look the component does not have. Declare the shape of any block a translator will see.

A record's system fields — `$name` and every other `$` key — are never extracted. The file a query writes for each record — the record whole — is translated with the same strings as the list, so a translated record page shows its translated body.

---

## Context-Specific Overrides

When the same source string appears in multiple places and needs different translations depending on context, use the override format:

```json
{
  "e5f6g7h8": {
    "default": "Learn More",
    "overrides": {
      "/pricing:cta": "See Pricing",
      "/about:intro": "Discover Our Story"
    }
  }
}
```

The `default` value is used everywhere except the specified overrides. Override keys use the format `{page}:{section}` matching the `contexts` in the manifest: the page's route, and the section's id — its file name without a number prefix (`2-cta.md` is `cta`), or the `id:` in its frontmatter. The id stays the same when files are renumbered, and an override survives pushing the site to a backend and pulling it back.

A page's own `title`, `label`, `description`, `keywords`, `seo.ogTitle` and `seo.ogDescription` use the section id `_meta` — `"/about:_meta"`. The site's own `seo.ogTitle` and `seo.ogDescription`, in `site.yml`, use `"/:_site"`. In `locales/records/`, an override names a record by the identity the records manifest lists in its `contexts` — `"std/article/habitat-protection"`.

---

## SEO Considerations

### Automatic hreflang Tags

The build generates proper `hreflang` tags for each page:

```html
<link rel="alternate" hreflang="en" href="https://example.com/about" />
<link rel="alternate" hreflang="es" href="https://example.com/es/about" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/about" />
<link rel="alternate" hreflang="x-default" href="https://example.com/about" />
```

### HTML lang Attribute

Each language's HTML includes the correct `lang` attribute:

```html
<html lang="es">
```

---

## Search Index

With i18n enabled, separate search indexes are generated per language:

```
dist/
├── search-index.json      # English
├── es/
│   └── search-index.json  # Spanish
└── fr/
    └── search-index.json  # French
```

The search client automatically uses the correct index for the active language.

---

## CLI Reference

| Command | Description |
| --- | --- |
| `uniweb i18n extract` | Extract translatable strings to `manifest.json` |
| `uniweb i18n init [locales]` | Generate starter language files from manifest |
| `uniweb i18n sync` | Update manifest with content changes |
| `uniweb i18n status [locale]` | Show translation coverage |
| `uniweb i18n audit [locale]` | Find stale and missing translations |
| `uniweb i18n init-freeform <locale> <path> <id>` | Create free-form translation |
| `uniweb i18n update-hash <locale> [<path> <id>]` | Update hash after source changes |
| `uniweb i18n move <old> <new>` | Move free-form translations |
| `uniweb i18n rename <path> <old-id> <new-id>` | Rename free-form translation |
| `uniweb i18n prune --freeform` | Remove orphaned free-form translations |

| Option | Applies to | Description |
| --- | --- | --- |
| `-t, --target <path>` | All | Specify site directory |
| `--verbose` | extract, sync, audit | Detailed output |
| `--dry-run` | sync, prune | Preview without writing |
| `--empty` | init | Empty strings instead of source text |
| `--force` | init | Overwrite existing files |
| `--clean` | audit | Remove stale entries |
| `--missing` | status | List missing strings |
| `--by-page` | status --missing | Group by page |
| `--freeform` | status, prune | Free-form translation mode |
| `--json` | status | Machine-readable output |
| `--records-only` | extract, status, audit | Record strings only (`locales/records/`) |
| `--no-records` | extract | Skip records (pages only) |
| `--all-stale` | update-hash | Update all stale hashes |

---

## Best Practices

1. **Write in default language first**: Complete content in your default language, then extract and translate
2. **Use `init` for new languages**: Run `uniweb i18n init` after extracting to get pre-populated files
3. **Use descriptive contexts**: The manifest shows where each string appears—use this to provide accurate translations
4. **Test all languages**: Check that layouts work with longer/shorter text in different languages
5. **Keep translations in sync**: Run `uniweb i18n sync` after content changes to update the manifest
6. **Use `--missing --json` for AI translation**: Export untranslated strings for batch translation with AI tools
7. **Consider RTL**: For Arabic, Hebrew, etc., you may need additional CSS for right-to-left layout

---

## See Also

- [Translating Your Site](../authoring/translating.md) — Content author guide (no coding required)
- [Site Configuration](../reference/site-configuration.md) — i18n settings in site.yml
- [Content Structure](../reference/content-structure.md) — How content flows to components
- [Site Search](../authoring/search.md) — Language-specific search indexes