# ADR-027 — SEO de ponta: service headless, JSON-LD `@graph` e arquivos de raiz

| Campo | Valor |
|---|---|
| **Status** | Aceito (2026-10-20) |
| **Data** | 2026-10-14 |
| **Plano de origem** | `wiki/plan/14-seo-performance.md` |
| **Relacionadas** | ADR-017, ADR-015, ADR-012, ADR-018, ADR-019, ADR-022, ADR-026 |

## Contexto

SEO é essencial para um portal governamental (transparência, cidadania, alcance). A implementação
do **`sucesu-cms`** (`V2/Services/SEO`), baseada no `service/seo` da **Concordia antiga**, é a
referência interna de maior grau: `SEOService` com estado por request, `bindModel()` lendo
`metadata['seo']` (**jsonb na própria tabela do módulo**), componente `seo-input` (preview de
snippet + contadores), `robots.txt`/`sitemap.xml` dinâmicos e **analytics de visitas/bots**. A
Concordia mais nova acrescenta: **SEO component-driven** (`<x-app-layout page-title … :schemas>`),
**JSON-LD `@graph`** (GovernmentOrganization + WebSite/SearchAction + GovernmentPage com sinais de
acessibilidade + schema do módulo), `security.txt`, manifest, sitemap índice+categorias, **Valkey
HyperLogLog** para visitas únicas (IP hasheado), indexação ativa (Google Indexing API), **301
redirects** e política anti-**soft-404**.

## Decisão

1. **`services/Seo`** agnóstico e headless (ADR-015): estado por request, `bindData(SEOData)`
   (readonly), fallbacks globais (site title/description/logo) e **sufixo de título**
   (`Título | Entidade`). **Sem trait** (ADR-001).
2. **Persistência por módulo em `metadata['seo']` (jsonb)** — ADR-017; o módulo grava via
   **`<x-service-seo::input>`** (componente de uso, ADR-015).
3. **Render component-driven:** **`<x-service-seo::tags>`** emite `<title>`, `meta description`,
   `canonical`, `robots`, OpenGraph/Twitter e o **JSON-LD `@graph`**. "Fonte da verdade" na view.
4. **JSON-LD governamental (`@graph`):** `GovernmentOrganization` + `WebSite` (`SearchAction`) +
   `GovernmentPage` (com `accessibilityAPI`/`accessibilityFeature` — sinal eMAG) + schema do
   módulo. Módulos: `NewsArticle`, `Legislation`, `GovernmentService`, `BreadcrumbList`,
   **`Dataset`** (ADR-018). Imagem OG vem do service **Media** (ADR-026), 1200×630.
5. **Arquivos de raiz dinâmicos** (controller nativo + cache Valkey): `robots.txt`,
   `sitemap.xml` (índice + sitemaps por tipo, `lastmod`, limite 20k), **`/.well-known/security.txt`**
   e `manifest.webmanifest`, com `Content-Type` correto e `Cache-Control`/`ETag` (ADR-022).
6. **Erros e redirects:** **404 real** (nunca soft-404) + log de link quebrado +
   `X-Robots-Tag: noindex`; **301 redirects** (tabela + middleware) preservando o legado (plano 15).
7. **i18n/SEO** (ADR-012): `hreflang` alternates + canônica **por locale** + `<html lang>`.
8. **Analytics privacy-first** (ADR-019): **sem Google Analytics por padrão**. Ranking interno via
   **Valkey HyperLogLog** (IP **hasheado**, sem PII) sincronizado para `seo.page_stats`.
   **Exceção opt-in (ADR-002):** o Google Analytics só é emitido quando o **painel de suporte**
   configura o `google_analytics_id` — carregado por JS **same-origin** (sem `<script>` inline,
   ADR-022), **consent-gated** (LGPD) e com `anonymize_ip`.
9. **Indexação ativa nativa:** ping de sitemap / **IndexNow** (HTTP, sem SDK). **Google Indexing
   API** só se justificado (`google/apiclient`) → **backlog**.
10. **Sem dependências:** Blade, Cache/Valkey, HTTP nativos.

## Justificativa (alternativas descartadas)

- **Trait `HasSEO`** (proposto na Concordia 05-5) — descartada: **traits proibidos** (ADR-001);
  `metadata['seo']` + componente cobrem.
- **Google Analytics como padrão** — descartada: ADR-019 (privacy-first, first-party). Como
  **opt-in** configurado no painel, é a única exceção de terceiro além do VLibras (ADR-002).
- **Google Indexing API no core** — descartada: dependência; **IndexNow** resolve nativamente.
- **DTO no controller + service na view** — descartada: component-driven é mais simples e visível.

## Consequências

**Positivas**
- SEO de ponta e governamental (rich snippets, `@graph`, acessibilidade sinalizada, dados abertos).
- Privacy-first por padrão; nativo; reuso por qualquer módulo.

**Custos / negativas**
- Exige disciplina de `metadata['seo']` e canonical/hreflang por rota.
- Sitemaps grandes precisam de cache e limite por arquivo.

## Exemplo mínimo (comparativo)

```blade
{{-- ❌ Trait no model + service na view + GA no head --}}
{{-- ✅ component-driven + @graph + sem 3rd-party --}}
<x-service-seo::tags
    title="Inauguração do Posto Central"
    description="A prefeitura entrega a reforma do Posto Central."
    :schema="['@type' => 'NewsArticle', 'headline' => '…', 'datePublished' => '…']" />

{{-- no CMS: --}}
<x-service-seo::input :model="$noticia" />   {{-- grava metadata['seo'] (jsonb) --}}
```

## Verificação

- `<x-service-seo::tags>` emite `@graph` válido (Rich Results Test) com acessibilidade sinalizada.
- `robots.txt`/`sitemap.xml`/`security.txt` 200 com tipo correto; só URLs públicas no sitemap.
- URL inexistente → **404** (não 200) e log de link quebrado; slug antigo → **301**.
- `hreflang` e canônica coerentes por locale; imagem OG 1200×630 do service Media.
- Nenhuma chamada a Google Analytics **por padrão** (sem ID configurado); ranking via HyperLogLog
  sem PII. Com ID configurado, o carregamento é same-origin, consent-gated e anonimizado.

## Emenda 2026-10-20 — paginação, alternates e ícones do manifest

- **Canônica de paginação é self-canonical por página:** `/noticias?page=2` declara
  `canonical=/noticias?page=2` (não consolida na página 1) e emite `<link rel="prev|next">` a partir
  do paginador da request. A view `<x-service-seo::tags>` aplica a query `page` também aos
  `hreflang`; o service `SeoService::resolve()` continua **puro** (não lê `url()->current()`).
- **Alternates só para path existente:** `Core\Localization\LocaleRoutes` filtra
  `Locales::alternatesForPath()` pelo roteador; um caminho registrado apenas no locale default não
  gera `/en/...` (404) no `<head>`. A conversão BCP-47 tem um único ponto (`Locales::bcp47()`).
- **Ícones do manifest vêm do Media/Gallery:** `seo.settings.pwa_icons` contém IDs do catálogo; o
  manifest usa `mime`/`width`/`height` reais e a rota `media.stream`. Sem IDs válidos, `icons: []`
  (nunca tamanhos inventados).
- **Redirect lê o `status` persistido** (301/302/307/308), em vez de devolver sempre 301.

## Referências

- `sucesu-cms/V2/Services/SEO/`; `cms/services/SEO/`, `cms/specs/3_seo/`,
  `cms/wiki/dev/seo_telemetria.md`; `wiki/plan/14-seo-performance.md`.
