# Spec 13 — i18n (pt_BR, en, es, fr)

> **Dono:** Core/i18n · **Status:** Vigente · **Plano:** 07
> **Relaciona:** ADR-012, ADR-004, ADR-014, ADR-016, ADR-017, ADR-022
> **Objetivo:** contrato de **internacionalização** nativa: `pt_BR` default + `en`/`es`/`fr`.
> Interface no `Lang/` de quem **é dono do fragmento**; conteúdo em `jsonb` via cast; locale
> determinado **pela URL**. Sem pacote, sem CDN, determinístico.

## 1. Locales e URL

- Locales: `pt_BR` (default), `en`, `es`, `fr`.
- **Default no root** (`/noticias`); demais com prefixo (`/en/noticias`, `/es/noticias`, `/fr/noticias`).
- **A URL é a autoridade do locale** — cacheável e determinística (ADR-022). O portal **não** usa
  sessão/cookie; `Accept-Language` no máximo redireciona **302** em `/` (nunca vira estado de request).
- A **área CMS** pode usar o idioma do **usuário** (campo do usuário), aplicado **só no CMS** e **não cacheado**.
- **Geração de URL — regra dura:** navegação interna usa **rota nomeada** (`route()`), que recebe o
  locale do `URL::defaults`; caminho **sem** rota nomeada (ex.: `/acessibilidade`, `/sitemap.xml`) usa
  `localized_url('/caminho')` (`Core\Localization\Locales::localize`). **Nunca** um caminho literal
  (`url('/x')`, `href="/x"`): ele perde o idioma silenciosamente. Isso é **gate**, não disciplina —
  `ModuleI18nTest::portal_and_module_views_do_not_hardcode_internal_paths` reprova o build (varre
  `layout/Portal`, `layout/Components` e `Layout/Views` de todo módulo; o CMS fica de fora, pois seu
  idioma é do usuário, não da URL).
- **Garantia provada na saída (não no fonte):** `LocaleUrlInvariantTest` — (a) **toda** rota do domínio
  do portal passa pelo resolvedor de locale (rota registrada fora da convenção reprova) e, gerada com
  `route()`, permanece dentro do locale; (b) **nenhum link interno** do HTML renderizado escapa do locale
  (varre `<a>`/`<form>` das páginas, resolvendo o host para caminho). Cobre módulo futuro, link montado
  em `config`/controller/JS e componente de serviço **sem o autor conhecer a regra** — foi assim que o
  `action` da busca (`/busca`, vindo de `config/layout.php`) foi detectado.
- Um único middleware no Core (`ResolveLocaleFromUrl`) resolve o locale pelo **primeiro segmento**;
  ele também limpa o parâmetro `locale` da rota (`forgetParameter`) — parâmetros de rota são passados
  **posicionalmente** ao controller, e o idioma não é assunto do controller.
- **Registro das rotas do portal:** o grupo é registrado duas vezes — **sem prefixo** (default) e com
  prefixo `{locale?}` restrito aos alternativos. Um parâmetro opcional **abrindo** o path
  (`{locale?}/noticias`) não casa a URL sem prefixo (compilador de rotas do Symfony), então o registro
  sem prefixo atende o default e o prefixado — registrado depois, portanto o escolhido por `route()` —
  gera a URL do locale vigente via `URL::defaults`.

## 2. Posse das strings (fronteira layout × módulo)

Cada string mora no **dono do fragmento** (ADR-016) — nunca duplicada:

| Dono | O que traduz | Onde |
|---|---|---|
| `src/layout` | **shell** (`header`/`nav`/`footer`) | namespace do layout |
| Módulo | o **fragmento** que publica no espaço (ex.: `<main>`) | `src/modules/{Grupo}/{Modulo}/Lang/{locale}/*.php` |
| Componente compartilhado | botões, paginação, rótulos a11y, erros RFC 7807 | namespace do Core/layout |
| Service | sua própria superfície | `src/services/{Nome}/Lang/{locale}/*.php` |

O módulo **puxa o layout** e implementa **somente o fragmento** do seu espaço + suas rotas
(ex.: Notícias preenche o `<main>` da página de notícias).

## 3. Carregamento (nativo, por convenção)

- Laravel nativo: `lang/{locale}/*.php` + `__()`/`@lang`.
- O Core carrega `Lang/{locale}/*.php` dos módulos/services **por convenção** (como `Layout/Views`/`Routes`,
  ADR-014/015), com **namespace = token canônico** (ADR-004): `__('noticias::messages.published')`.
- **Arquivo = seção da chave** (resolvedor do Laravel): `Lang/{locale}/index.php` responde
  `{token}::index.*`; `menu.php` → `{token}::menu.*`. O `name-check` isenta `Lang/**` (o token está no
  caminho) — a exceção é documentada em `tools/name-check.sh`.
- **O manifesto guarda a chave, não o texto:** `ModuleProvider::config()['title'] = 'token::menu.title'`;
  quem renderiza a navegação resolve com `__()` (a navegação do CMS é traduzível por locale).
- **Sem pacote** de tradução; **sem string fixa** solta no HTML.

## 4. Conteúdo (domínio) — `jsonb` + cast nativo

- Campos traduzíveis em **JSONB keyed by locale** (`{"pt_BR":"…","en":"…","es":"…","fr":"…"}`) — ADR-017.
- **`LocaleCast`** (`CastsAttributes`):
  - `get` devolve o valor do **locale atual** com **fallback** para o default;
  - `set` grava o **objeto completo** (o CMS posta todas as locales) — **não** adivinha o locale pela aba;
  - o CMS lê as traduções cruas (`getTranslations('campo')`) para o **indicador de tradução faltante**.
- **Nunca HTML** (ADR-017); **sem `spatie/laravel-translatable`** (dependência + trait).

## 5. Slugs, SEO e acessibilidade

- **Slugs** por locale em `jsonb`; URL antiga sem prefixo → **301** para o locale resolvido.
- `hreflang` alternates no `<head>` + **canônica por locale** (serviço de SEO, ADR-027).
- `<html lang>` (WCAG 3.1.1) e `lang` em trechos de outro idioma (3.1.2).
- **`hreflang` + `x-default`** no `<head>` a partir do path atual (`Locales::alternatesForPath`), com
  canônica por locale (ADR-027). Alternates só entram quando o path **existe no roteador**
  (`LocaleRoutes`); paginação usa self-canonical (`?page=N`) + `rel=prev/next` e BCP-47 vem de
  `Locales::bcp47()` (ponto único).
- **Formatação:** `intl` nativo via `Core\Localization\LocalizedDate` (`IntlDateFormatter`:
  `14 de outubro de 2026` / `October 14, 2026`) + `Carbon::setLocale`; sem libs.

## 6. CMS — aba por locale (conteúdo)

O editor de dados do módulo tem uma **aba por locale** (pt_BR/en/es/fr) para o autor transcrever o
**conteúdo** (campo `jsonb` + `LocaleCast`), com indicador de tradução faltante e fallback visível.
A tradução **fixa** do fragmento vem do `Lang/` do módulo (§3), **não** do CMS.

## 7. Mapa de código

| Artefato | Caminho |
|---|---|
| Locales suportados + resolução | `src/core/Localization/Locales.php` |
| Datas por locale (`intl`) | `src/core/Localization/LocalizedDate.php` |
| Middleware de locale (URL) | `src/core/Internal/Http/Middleware/ResolveLocaleFromUrl.php` |
| Carregador `Lang/` por convenção | `ModuleDiscoveryServiceProvider` / `ServiceDiscoveryServiceProvider` |
| Rotas localizadas do portal | `src/core/Internal/Routing/AreaRegistrar.php` |
| Tradução do fragmento | `src/modules/{Grupo}/{Modulo}/Lang/{locale}/{seção}.php` |
| Tradução do shell | `src/layout/Lang/{locale}/*.php` (namespace `layout`) |
| `Lang/` dos services | `src/services/{Nome}/Lang/{locale}/{seção}.php` |
| Cast de conteúdo (conteúdo por locale) | `LocaleCast` — **F4.I1** (ainda não existe) |

## 8. Verificação

- **Estrutural** (`tests/Feature/ModuleI18nTest.php`): todo módulo com `Layout/Views` tem as **4 locales**;
  **paridade de chaves** entre locales (módulos, services e layout); views de módulo só usam o
  namespace **próprio** ou `layout::`; **nenhum texto fixo visível** (nós de texto sem `__()`).
- **Comportamento** (`tests/Feature/I18nTest.php`): locale pela URL (default no root, prefixo nos demais,
  `pt_BR`/desconhecido → 404), `lang` no shell, `route()` mantendo o locale vigente, `hreflang`,
  `localize()` (idempotente; preserva absolutos/âncoras/`mailto:`) e os links de **estrutura**
  (marca, rodapé, breadcrumb) prefixados na página alternativa; `LocalizedDateTest` cobre as datas.
- **E2E** (`src/tests-e2e`): as histórias de a11y rodam com o shell localizado.
- **Pendente (F4.I1):** conteúdo por locale (`jsonb` + `LocaleCast`) e a aba por locale no CMS.

## 9. Não-objetivos (deste momento)

- Segmentação de locale por usuário no portal (`Accept-Language` como estado) — proibido (ADR-012 §5).
- Tradução automática de conteúdo; pacotes de i18n JS; CDN de qualquer tipo.
