<!--
	covers: messages base/UrlManager.php config/components/urlManager.php config/components/i18n.php controllers/Controller.php functions.php helpers/Language.php models/TranslationQueue.php services/AiTranslationService.php
	verified: 3c56e1b
-->
# i18n and URLs

Three languages, fully translated URL paths, translations in a JSON column.

## Languages

`APP_LANGUAGES = [ 'hu' => 'Magyar', 'en' => 'English', 'de' => 'Deutsch' ]`, `APP_DEFAULT_LANGUAGE = 'hu'`.

The default language has **no URL prefix**; the others are prefixed with their code. `hu/` is not a valid
prefix — `Language::fromPath()` explicitly excludes the default.

## Two translation systems

They are unrelated and easy to confuse:

| | Static text | Content |
|---|---|---|
| Where | `messages/{hu,en,de}/{frontend,admin}.php` | the `translations` JSON column on each record |
| Read with | `t()` / `bt()` | transparently, by `ActiveRecord::populateRecord()` |
| Written by | a developer | editors in the admin, or the AI translation run |

### `t()` returns `''`, not the key

[`functions.php:16`](../../functions.php):

```php
$translation = Yii::t( $category, $message, $params, $language );
return $translation == $message ? '' : $translation;
```

**Code depends on this.** `View::getMeta()` builds a fallback chain out of it, and views use
`t( 'x' ) ?: something` freely. A missing key renders nothing — it never leaks a key into the page, and it
never throws. The cost: a typo in a key is invisible.

`bt()` is `t()` with the `admin` category. The admin is Hungarian only, so `messages/en/admin.php` and
`messages/de/admin.php` need no entries.

### Frontend labels are always trilingual

A new frontend key goes into all three of `messages/{hu,en,de}/frontend.php`. One-language keys silently
render as empty strings in the other two.

**Voice: informal.** Hungarian uses *tegeződés* ("te"), never "Ön"; German uses "du", never "Sie". This was
a deliberate correction across the whole copy — see the commits `Docs: frontend copy uses informal address`
and `… make German copy informal`.

## Content translations

Stored per record in a `translations` JSON column, declared by `protected array $multilingualAttributes` on
the model. The read/write swapping is entirely inside
[`ActiveRecord`](ACTIVE-RECORD.md) — no consumer code is language-aware.

Untranslated values fall back to the source (Hungarian) value, and untranslated **slugs** stay reachable
under the Hungarian slug in every language (`ActiveRecord::findBySlug()`).

### AI translation

- **On save**: when `APP_AUTO_TRANSLATE_ENABLED`, `ActiveRecord::queueTranslations()` invalidates
  translations whose source changed and enqueues the record into
  [`TranslationQueue`](../../models/TranslationQueue.php).
- **The cron** `./yii translate/auto` drains the queue, time-boxed and guarded by a `MysqlMutex` named
  `translate-auto`. It exits immediately if the flag is off or `ANTHROPIC_API_KEY` is empty.
- **Manual**: `./yii translate/model <Model>` translates a whole model, reviewed by hand.
- The client is [`services/AiTranslationService.php`](../../services/AiTranslationService.php), talking to
  the Anthropic Messages API (`ANTHROPIC_*` constants).

After a bulk translation run the search index is stale — rerun `./yii search-index/build`.

## URL rules

[`config/components/urlManager.php`](../../config/components/urlManager.php) **generates** the rule set at
config time from three arrays.

### `$technical` — not localized

Admin routes, `contents/<filename>` → `pictures/generate`, `captcha`, `robots.txt`,
`sitemap.xml`, `sitemap/<action>.xml`, `site/ping-session`, and two legacy redirects
(`koltoztetes`, `dokumentumok`).

### `$words` — the path dictionary

One entry per path word, with a value per language:

```php
'storages' => [ 'hu' => 'tarolok', 'en' => 'storages', 'de' => 'lager' ],
'proposal' => [ 'hu' => 'ajanlatkeres', 'en' => 'request-a-quote', 'de' => 'angebot-anfordern' ],
```

### `$localized` — the templates

Patterns using `{word}` placeholders, mapped to routes:

```php
'{storages}/<location>/<slug>' => 'storages/display',
'{articles}/<page:\d+>'        => 'articles/index',
'<slug:[a-z0-9-]+>'            => 'contents/display',   // catch-all, must stay last
```

`$buildGroup( $language )` substitutes the words, prefixes non-default languages with `<code>/`, and attaches
`'defaults' => [ 'language' => $language ]` to every rule.

### Registration order is load-bearing

```php
$rules = $technical;
foreach( APP_LANGUAGES as $code => $name )
    if( $code !== APP_DEFAULT_LANGUAGE )
        $rules = array_merge( $rules, $buildGroup( $code ) );
$rules = array_merge( $rules, $buildGroup( APP_DEFAULT_LANGUAGE ) );
```

Technical rules first, then **every non-default language**, then the default language **last**. The default
group is last precisely because it is unprefixed and its final rule
`'<slug:[a-z0-9-]+>' => 'contents/display'` matches almost anything — put it earlier and it would swallow
`de/lager`.

Within `$localized`, order matters the same way: `'{storages}/budapest/<slug>'` precedes
`'{storages}/<location>/<slug>'`, which precedes `'{storages}/<slug>'`. More specific first.

**Adding a localized route means editing two arrays** — a `$words` entry per new path word and a `$localized`
template — and the route is auto-registered in `localizedRoutes`, derived at the bottom of the file.

## Language resolution order

1. `WebApplication::__construct()` → `Language::fromPath()` — first path segment, pre-routing guess.
2. `UrlManager::parseRequest()` → the matched rule's `language` default — authoritative.
3. `Module::init()` in the admin forces `APP_DEFAULT_LANGUAGE` — the backend is always Hungarian.

## URL generation

`UrlManager::createUrl()` injects the current language for any route listed in `$localizedRoutes`, so
ordinary `Url::to( [ 'articles/display', 'slug' => … ] )` calls stay language-agnostic. Pass
`'language' => 'de'` explicitly to force another language.

## hreflang and the language switcher

Both come from [`Controller::getAlternateUrls()`](../../controllers/Controller.php):

```php
foreach( APP_LANGUAGES as $code => $name )
    $urls[ $code ] = $this->urlInLanguage( $code, $scheme );
```

`urlInLanguage()` has two branches:

- **detail pages** (`$this->model && $this->model->displayAction`) — clone the model, `translate()` it, and
  build the URL from its `urlParameters`, so the **slug is translated too**;
- **everything else** — `Url::current( [ 'language' => $language ] )`, keeping the route and swapping the
  prefix.

`View::head()` emits one `<link rel="alternate" hreflang>` per language plus `x-default` pointing at the
Hungarian URL, but **only when `getAlternateUrls()` exists on the controller** (`components/View.php:104`).

> A controller with extra translated path segments beyond the model slug must override `urlInLanguage()` —
> the default branch cannot know about them.

## Traps

1. **`t()` is silent on a typo.** Nothing warns; the page just has a gap.
2. **A new localized route needs both `$words` and `$localized`.** Missing the `$words` entry makes
   `preg_replace_callback` fall back to the Hungarian word for every language.
3. **The catch-all content rule must stay last** in `$localized`.
4. **The admin ignores the request language entirely.** Editors always see the Hungarian source values.
5. **Editing a name rewrites the slug** in every language, with no redirect left behind.
