feat(i18n): core Locale resolver + I18n t()-helper with tests (closes #72)
- App\Core\Locale: query-param > cookie > Accept-Language > 'de' fallback
- BCP-47 region stripping (en-US -> en, uk-UA -> uk)
- q-value sorting with stable order
- og:locale mapping (de_DE, en_GB, uk_UA, ru_RU)
- hreflang alternates helper
- App\Core\I18n: t() with {placeholder} interpolation, lookup chain
current-locale -> de -> key, in-memory cache
- ADR-002: documents the architecture decision
- 46 PHPUnit tests (LocaleTest, I18nTest), all green
This commit is contained in:
123
docs/adr/002-multilanguage-architecture.md
Normal file
123
docs/adr/002-multilanguage-architecture.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# ADR-002: Multi-Language Architecture (i18n)
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-04
|
||||
**Context:** Issue #71 (Epic: Multi-Language: UK/RU/EN — DE bleibt für Rechtliches)
|
||||
**Deciders:** Martin (Product Owner), Hermes (Implementation)
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
The landing page `landingpage-haus-schleusingen.de` is currently German-only.
|
||||
It must support 4 languages: **DE** (default, for legal content), **EN** (UK
|
||||
English), **UK** (Ukrainian), **RU** (Russian). The site is SEO-critical (real
|
||||
estate listing), has no build step, and runs on stock PHP 8.x on shared hosting.
|
||||
|
||||
The challenge: ship server-side rendering for SEO + no FOUC, without dragging
|
||||
in a heavy framework or build pipeline.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **PHP-Server-Side-Rendering with `app/Locales/*.php` arrays + `t()` helper**
|
||||
(chosen)
|
||||
2. JSON translation files + JS-driven i18n (rejected — FOUC, bad SEO)
|
||||
3. Full Symfony/translation component (rejected — overkill for 1-page site)
|
||||
4. Static-site per language (`/de/`, `/en/`, `/uk/`, `/ru/` directories)
|
||||
(rejected — duplicates routes/forms, harder to maintain)
|
||||
|
||||
## Decision
|
||||
|
||||
**Option 1: PHP SSR with `app/Locales/*.php` and a `t()` helper.**
|
||||
|
||||
### Components
|
||||
|
||||
- **`App\Core\Locale`** — locale resolution (priority: query-param `?lang=`
|
||||
→ cookie → `Accept-Language` header → fallback `de`). Immutable, no
|
||||
globals. Available locales: `['de', 'en', 'uk', 'ru']`.
|
||||
- **`App\Core\I18n`** — translation loader + `t(string $key, array $params = [])`
|
||||
function. Loads `app/Locales/{locale}.php` lazily, caches in static array.
|
||||
Supports `{placeholder}` interpolation. Falls back to `de` if a key is
|
||||
missing in the current locale, then to the key itself.
|
||||
- **`App\Controllers\LocaleController`** — `GET /locale/{locale}` sets a
|
||||
one-year `locale` cookie and 302-redirects to `Referer` (or `/`).
|
||||
- **`app/Locales/{de,en,uk,ru}.php`** — flat `key => 'text'` arrays. Keys use
|
||||
dotted notation (`nav.gallery`, `hero.cta`, `form.error.email`).
|
||||
- **Layout** — `app/views/layouts/main.php` reads current locale from
|
||||
`Locale::current()` and renders dynamic `<html lang="…">` + `og:locale`.
|
||||
- **Switcher UI** — in `app/views/partials/locale_switcher.php`, embedded in
|
||||
navbar. Inline SVG flag icons (no external assets).
|
||||
|
||||
### Locale Resolution Order
|
||||
|
||||
1. Query parameter `?lang=xx` (one-shot, sets cookie)
|
||||
2. Cookie `locale` (1 year, `SameSite=Lax`, no `Secure` flag for HTTP test
|
||||
hosts, `Secure` flag in prod via env check)
|
||||
3. `Accept-Language` header — first matching language from
|
||||
`['en-US', 'en', 'uk', 'ru']` (BCP-47 → ISO 639-1 mapping)
|
||||
4. Fallback: `de`
|
||||
|
||||
### Translation File Format
|
||||
|
||||
```php
|
||||
// app/Locales/de.php
|
||||
return [
|
||||
'nav.gallery' => 'Galerie',
|
||||
'hero.cta' => 'Jetzt anfragen',
|
||||
'form.label.email' => 'E-Mail',
|
||||
];
|
||||
```
|
||||
|
||||
`{placeholder}` interpolation, e.g.:
|
||||
|
||||
```php
|
||||
'greeting' => 'Hallo, {name}!',
|
||||
echo t('greeting', ['name' => 'Martin']); // "Hallo, Martin!"
|
||||
```
|
||||
|
||||
### Out of Scope (this MVP)
|
||||
|
||||
- Right-to-left languages (Arabic, Hebrew)
|
||||
- Plural forms (`{n,plural,one{...}other{...}}` ICU syntax) — flat strings only
|
||||
- Admin UI for editing translations (POEditor, Crowdin, etc.)
|
||||
- Per-page translation overrides
|
||||
- URL-based locale (`/en/`, `/uk/`) — cookie + query only for MVP
|
||||
|
||||
### Trade-offs Accepted
|
||||
|
||||
- **No URL-based locale** → weaker SEO signal for non-default languages.
|
||||
Mitigation: `og:locale` + `<html lang>` + hreflang tags (TODO post-MVP).
|
||||
- **No ICU plural** → manual `{n} Zimmer` strings. Acceptable: page has
|
||||
fixed numbers (`227 m²`, `6 Zimmer`).
|
||||
- **Flat key namespace** (`nav.gallery` vs nested arrays) → slightly more
|
||||
verbose but trivially diff-able in PRs and avoids PHP array-merging
|
||||
surprises.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Zero new dependencies** (no Composer additions, no JS framework)
|
||||
- **SEO-perfect** — fully server-rendered, no FOUC
|
||||
- **Trivially testable** — pure PHP, no globals, no I/O at request time
|
||||
(files loaded once, cached)
|
||||
- **Diff-friendly** — translation files are flat PHP arrays
|
||||
- **Fast** — locale detection is in-memory; translation load happens once
|
||||
per request, per locale
|
||||
|
||||
### Negative
|
||||
|
||||
- Adding a new key requires touching all 4 files (mitigated by `missing
|
||||
key → fallback to DE → fallback to key string`)
|
||||
- No URL canonicalization for non-DE locales (mitigated post-MVP with
|
||||
hreflang)
|
||||
- Manual translation review (no professional translator for UK/RU in MVP)
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
Issue #71 (Epic) → #72–#77 (6 sub-issues, dependency-ordered).
|
||||
|
||||
## References
|
||||
|
||||
- Issue #71: https://git.home.kies-media.de/greggy/landingpage-haus-schleusingen/issues/71
|
||||
- Issue #72–#77: sub-issues, all in Milestone "Multi-Language MVP"
|
||||
- W3C i18n tutorials: https://www.w3.org/International/tutorials/
|
||||
- BCP-47 language tags: https://datatracker.ietf.org/doc/html/rfc5646
|
||||
Reference in New Issue
Block a user