# Módulo Páginas (`Portal/Paginas`)

> Contrato de leitura do módulo de **páginas estáticas** (ADR-004/ADR-014/ADR-016): a tabela `pages`,
> o CMS (lista/criar/editar/transições), a **regra de slug** (P5.S3.1) e o **portal** — fallback
> `/{slug}`, render AST, SEO, sitemap e prévia autenticada (P5.S3.2).

## Propósito

- **Grupo:** `Portal` (organizacional — não entra no token).
- **Áreas:** CMS (lista, formulário, transições e prévia) e Portal (fallback `/{slug}` e
  `sitemap-paginas.xml`).
- **Token:** `paginas`.
- **Ativação:** dado em `core_module_discovery_settings` (ADR-034):
  `php artisan core:module:activate Paginas [--disable]`. Módulo inativo → o fallback responde 404 e
  o sitemap da fonte fica vazio (o `module.activation` da área cuida do portal).

## Estrutura (contrato físico)

```
src/modules/Portal/Paginas/
├── ModuleProvider.php                    # permissions paginas.{ver,editar,arquivar}; rota do painel; fonte do sitemap
├── Routes/cms.php                        # GET /paginas, /criar; POST /paginas; GET /{id}/editar; PUT /{id}; POST publicar/despublicar/arquivar; GET preview
├── Routes/portal-fallback.php            # GET /{slug} (e /{locale}/{slug}) — fallback da área Portal
├── Logic/Controllers/PaginaShowController.php         # 200 só publicado; 404 noindex para rascunho/arquivada
├── Logic/Controllers/Cms/PaginaIndexController.php    # lista + filtros (VIEW)
├── Logic/Controllers/Cms/PaginaCreateController.php   # formulário vazio (EDIT)
├── Logic/Controllers/Cms/PaginaEditController.php     # formulário do registro + ações de visibilidade (EDIT)
├── Logic/Controllers/Cms/PaginaStoreController.php    # 403 → validação → Action → redirect
├── Logic/Controllers/Cms/PaginaUpdateController.php   # 403 → validação → Action → redirect
├── Logic/Controllers/Cms/PaginaPublishController.php  # 403 → publicar (EDIT) → redirect
├── Logic/Controllers/Cms/PaginaUnpublishController.php# 403 → despublicar (EDIT) → redirect
├── Logic/Controllers/Cms/PaginaArchiveController.php  # 403 → arquivar (ARCHIVE) → redirect
├── Logic/Controllers/Cms/PaginaPreviewController.php  # prévia autenticada (VIEW), sem cache
├── Logic/Actions/SavePagina.php          # slug + AST + SEO; 422 por campo; span/log; esquece o cache
├── Logic/Actions/TransitionPagina.php    # publicar/despublicar/arquivar; 409; auditoria; esquece o cache
├── Logic/Requests/SavePaginaRequest.php  # forma do formulário (não o shape do AST)
├── Logic/DTOs/PaginaData.php             # entrada validada (slug, título, conteúdo, SEO)
├── Logic/DTOs/PaginaRenderData.php       # estado de render público (título/descrição/canônica/HTML)
├── Logic/Enums/PaginaStatus.php          # rascunho | publicado | arquivado
├── Logic/Exceptions/PaginaTransitionConflict.php # transição inválida → 409
├── Logic/Support/PaginaSlug.php          # normalização, unicidade, sufixo determinístico
├── Logic/Support/PaginaReservedSlugs.php # primeiros segmentos de rota viva (vindos do router)
├── Logic/Support/PaginaContent.php       # validação do AST TipTap (ADR-028)
├── Logic/Support/PaginaSeo.php           # SEO básico (título/descrição por locale + robots)
├── Logic/Support/PaginaRenderer.php      # AST → HTML, SEO por locale, imagem Media, h1 único; cache
├── Logic/Support/PaginaCache.php         # chave/invalidação do render público
├── Logic/Support/PaginaFormState.php     # estado inicial do formulário (criar/editar)
├── Seo/PaginaSitemapSource.php           # só publicadas no sitemap (ADR-027)
├── Database/Migrations/2026_10_25_000001_create_paginas_pages_table.php
├── Database/Models/Pagina.php            # `paginas.pages`
├── Layout/Views/cms/index.blade.php      # lista (shell + filtros + tabela)
├── Layout/Views/cms/form.blade.php       # formulário (títulos/SEO por locale + editor rico) + visibilidade
├── Layout/Views/show.blade.php           # página pública e prévia (shell do portal + AST)
├── Lang/{pt_BR,en,es,fr}/index.php, cms.php
└── Test/PaginaCmsTest.php, PaginaSchemaTest.php, PaginaSlugTest.php, PaginaPortalTest.php
```

## Rotas

| Nome | Método | URI | Controller | Acesso |
|---|---|---|---|---|
| `paginas.cms.index` | GET | `/cms/paginas` | `PaginaIndexController` (invocável) | `paginas.ver` |
| `paginas.cms.create` | GET | `/cms/paginas/criar` | `PaginaCreateController` | `paginas.editar` |
| `paginas.cms.store` | POST | `/cms/paginas` | `PaginaStoreController` | `paginas.editar` (freio `throttle:60,1`) |
| `paginas.cms.edit` | GET | `/cms/paginas/{pagina}/editar` | `PaginaEditController` | `paginas.editar` (binding por UUID) |
| `paginas.cms.update` | PUT | `/cms/paginas/{pagina}` | `PaginaUpdateController` | `paginas.editar` (freio `throttle:60,1`) |
| `paginas.cms.publish` | POST | `/cms/paginas/{pagina}/publicar` | `PaginaPublishController` | `paginas.editar` (freio `throttle:60,1`, CSRF) |
| `paginas.cms.unpublish` | POST | `/cms/paginas/{pagina}/despublicar` | `PaginaUnpublishController` | `paginas.editar` (freio `throttle:60,1`, CSRF) |
| `paginas.cms.archive` | POST | `/cms/paginas/{pagina}/arquivar` | `PaginaArchiveController` | `paginas.arquivar` (freio `throttle:60,1`, CSRF) |
| `paginas.preview` | GET | `/cms/paginas/{pagina}/preview` | `PaginaPreviewController` | `paginas.ver` (sessão do CMS) |
| `paginas.portal.show` | GET | `/{slug}` e `/{locale}/{slug}` | `PaginaShowController` | pública (só `publicado`; 404 noindex nos demais) |

As rotas CMS vivem no domínio do painel e nascem autenticadas pelo `AreaRegistrar` (ADR-037). A
autorização é do controller (`Permission::authorize`, ADR-033) — **403 antes** de qualquer 422/409 —
e as mutações são `POST`/`PUT` com CSRF (nunca `GET` mutando). O portal **não** é uma rota comum: é
o fallback da área (`Routes/portal-fallback.php`), carregado pelo `AreaRegistrar` **depois** das
rotas vivas e **antes** do 404 da área; por isso `/{slug}` nunca sombreia `/noticias`,
`/dados-abertos` ou qualquer rota de módulo/service.

## Permissões (ADR-033)

O manifesto declara `paginas.ver`, `paginas.editar` e `paginas.arquivar` (sem `aprovar`). Ler a
lista, o formulário e a **prévia** exige `ver`; criar/editar/gravar e **publicar/despublicar** exigem
`editar`; **arquivar** exige `arquivar` (ação própria, separada de editar). Tudo é negado por padrão.

## Dados (ADR-007/ADR-017)

- Schema `paginas`, tabela `pages`: `id` (uuidv7), `slug` (texto), `title` (jsonb — texto por
  locale), `content` (jsonb — AST TipTap por locale, nulo quando vazio), `seo` (jsonb),
  `layout_scope` (texto nulo), `status` (`rascunho|publicado|arquivado`), `published_at`
  (timestamptz nulo), `created_at`/`updated_at`.
- **Slug:** normalizado (minúsculas ASCII, hífens simples, ≤ 120 caracteres) e **único sempre** —
  índice único sobre `lower(slug)`, inclusive para páginas arquivadas (URL estável). Um slug que
  colidiria com o **primeiro segmento de rota viva** (`PaginaReservedSlugs` — lido do router, mais
  `cms`/`api`/`home`/`up` e os prefixos de idioma) é recusado com 422: a página nunca sombreia uma
  rota. Slug em branco é derivado do título (locale default) com sufixo determinístico (`-2`, `-3`…).
- **CHECKs como contrato:** formato/comprimento do slug, `title` objeto não vazio, `content`/`seo`
  objetos, `status` no vocabulário, `publicado` exige `published_at` e `layout_scope` no vocabulário
  de escopo (`*` ou segmentos `a-z0-9-`/pontos).
- **Conteúdo:** AST do TipTap (`{type: "doc", content: [...]}`), validado por `PaginaContent`
  (raiz `doc`, `content` sempre lista, nós proibidos — `html`/`iframe`/`script`/`style`/`template`/
  `blade` — e chave proibida em qualquer nível, reusando `BlocksSchema::isForbiddenKey`; tetos de
  profundidade e de nós). Documento inválido = **422 por locale**, nunca 500 e nunca gravação
  parcial. Nada de HTML/CSS/Blade no `jsonb` (ADR-017/ADR-028).
- **Texto:** título e SEO são texto simples por locale (`LocalizedTextCast`) — marcação HTML/Blade
  no texto é recusada (mesma regra do catálogo de blocos). `seo` = `{title: {locale: …},
  description: {locale: …}, robots: "noindex"|null}` (o `metadata['seo']` do service Seo, ADR-027).
- **Visibilidade:** `rascunho → publicado` grava `published_at` (relógio injetado); `publicado →
  rascunho` limpa a data; `rascunho|publicado → arquivado` preserva a data histórica. Arquivada não
  volta (`arquivar` é terminal nesta fatia). Toda combinação fora dessa matriz é **409**.
- `layout_scope` existe como **contrato de dado**; nesta fase não tem campo na tela e permanece nulo
  (resolução pelo escopo da área).
- `down()` renomeia a tabela e os índices explícitos para `backup_*`; nunca dropa (ADR-017).

## Portal, cache e SEO (P5.S3.2)

- **Fallback:** `GET /{slug}` e `GET /{locale}/{slug}` só respondem 200 para página **publicada**;
  slug inexistente, rascunho e arquivada são **404 real** com `X-Robots-Tag: noindex` (nunca
  soft-404 — ADR-027). Redirect legado (`seo_redirects`) continua valendo **antes** do fallback.
- **Render:** `<x-layout::portal>` com `title`, `description`, `canonical`, `robots`, `image`,
  `breadcrumbs` e `schemas`; o `<h1>` é o título da página e os títulos do conteúdo descem um nível
  (nunca dois `<h1>`). O corpo é o HTML semântico do `RichTextRenderer` (ADR-028) — nunca marcação do
  banco. Título/descrição do `seo` são resolvidos **por locale** (fallback: título do conteúdo e
  resumo em texto puro); a imagem social é a primeira mídia do AST, resolvida pelo service Media.
- **Locale:** o middleware da área remove o parâmetro `locale`; a canônica usa a rota nomeada (com o
  prefixo do idioma) e o `hreflang`/`x-default` é do shell (ADR-012/ADR-027). Página `noindex` não
  declara canônica nem alternates.
- **Cache:** `paginas.page.{id}.{locale}` (TTL `config('paginas.cache_ttl')`), esquecido **na hora**
  por salvar/publicar/despublicar/arquivar (ADR-022); duas requests iguais servem o corpo cacheado e
  a escrita aparece na requisição seguinte. A prévia autenticada **não** usa esse cache.
- **Sitemap:** `PaginaSitemapSource` (`id` `paginas`) contribui somente publicadas para
  `sitemap-paginas.xml`, com `lastmod`, URLs localizadas e `alternates`; o XML/ordenação/`ETag`/304
  são do service Seo (ADR-027). `/cms` e `/api` nunca entram.
- **Prévia autenticada:** `paginas.preview` na sessão do CMS (`paginas.ver`), mesmo shell/render do
  portal, `noindex, nofollow` (meta + `X-Robots-Tag` da área restrita), sem canônica, sem token
  público e sem cache — nenhum rascunho é acessível a anônimo.

## Eventos e observabilidade (ADR-024/ADR-035)

- **Emite:** `paginas.save` (span + `log_event` com `id`/`slug`), `paginas.publish`,
  `paginas.unpublish` e `paginas.archive` (span + `log_event` com `id`/`slug`; sem PII).
- **Auditoria:** toda mutação de `Pagina` entra na trilha pelo `GlobalAuditSubscriber` (área CMS =
  full — `paginas.created`/`paginas.updated`, ator snapshot, ADR-035). As transições gravam
  **evidência explícita** `paginas.published`/`paginas.unpublished`/`paginas.archived` com
  `before`/`after` do `status`. Recusas (422/409) não gravam nada.

## Layout (ADR-016)

- Lista e formulário usam **um** `<x-layout::cms>` e preenchem só o `<main>`; o `<h1>` é do shell e o
  módulo entrega `<h2>`/`<legend>` (grupos de campos).
- O formulário tem um campo por locale no título e no SEO (sem JS: abas são só rótulos; todos os
  campos são submetidos) e o conteúdo usa o editor único do projeto (`<x-richtext::editor>`,
  ADR-028) — com JS o TipTap assume; sem JS o `<textarea>` com o AST JSON continua editável.
- Visibilidade: publicar/despublicar/arquivar são **formulários próprios** (fora do formulário de
  edição — sem `<form>` aninhado), com CSRF, botão nativo e o caminho sem JS; só a transição válida
  para o estado atual aparece, e a permissão decide a visibilidade do botão (o servidor é a verdade).
  A prévia autenticada é oferecida por locale (links `GET`).
- Erros: resumo `role="alert"` (`x-cms::form-errors`) + mensagem no campo com `aria-invalid` e
  `aria-describedby`. Nenhuma ação depende de JS.
- Página pública: `<x-layout::portal>` com um `<h1>` (título), AST em `div.richtext` e breadcrumb
  (`Início` + página) — sem `style=""`, sem JS proprietário e sem CDN.

## Comandos

```bash
php artisan core:module:activate Paginas
php artisan core:module:activate Paginas --disable
php artisan route:list --name=paginas.
```

## Verificação

```bash
vendor/bin/phpunit --filter='PaginaCmsTest|PaginaSchemaTest|PaginaSlugTest|PaginaPortalTest'
bash tools/name-check.sh --grep paginas
make check
make check E2E=1
```
