# ADR-028 — Editor de texto rico único: TipTap (AST JSON) + parser PHP

| Campo | Valor |
|---|---|
| **Status** | Aceito (2026-10-20) |
| **Data** | 2026-10-15 |
| **Plano de origem** | `wiki/plan/12-admin-cms-editor.md` |
| **Relacionadas** | ADR-001, ADR-002, ADR-009, ADR-010, ADR-012, ADR-013, ADR-015, ADR-017, ADR-020, ADR-022, ADR-025, ADR-026 |

## Contexto

O CMS precisa de um **editor de texto rico robusto** para publicações oficiais. Duas referências
concorrem:

- **`cms/` (Concordia)** usava o `RichEditor` do **Filament** — que é construído sobre **TipTap** —
  mas acoplado ao Filament (sendo removido, ADR-009).
- **`sucesu-cms/V2/Services/ContentRichEditor`** provou uma arquitetura **desacoplada e estável**:
  o editor TipTap produz um **AST JSON**; o backend PHP (`ContentASTParser` + um renderer por
  nó/mark) traduz esse AST em **HTML semântico** no portal. Sem HTML livre, sem CDN, com
  visualização fiel ao editor (split-screen).

O projeto exige **um único editor** (não vários), conteúdo **determinístico e seguro** (sem HTML
armazenado), acessibilidade (eMAG/WCAG 2.2 AA), zero CDN (ADR-002) e compatibilidade com CSP
hash-based (ADR-020). A decisão anterior (ADR-009) proibia "TipTap"; ela é **substituída** por
esta: **sai o Filament, fica o TipTap**.

## Decisão

1. **TipTap é o único editor de texto rico do projeto.** Nenhum outro editor (CKEditor, TinyMCE,
   Quill, editor de HTML, RichEditor do Filament) pode ser introduzido sem nova ADR.
2. **Arquitetura de 3 camadas desacopladas** (como no `sucesu-cms`, polida e simplificada):
   - **Edição (front):** TipTap vendorizado (npm) e empacotado pelo Vite; gera/atualiza um campo
     oculto com o **AST JSON**.
   - **Persistência:** o AST é salvo em coluna **`jsonb`** (ADR-017) — **nunca HTML**.
   - **Visualização (back):** `ContentASTParser` PHP percorre o AST e delega a **um renderer
     concreto por nó/mark**, produzindo **HTML semântico** (portal) e **Markdown/texto puro**
     (resumos/SEO). Toda saída de texto é escapada (anti-XSS).
3. **Camada de serviço:** o parser/renderer vive em **`src/services/RichText`** (headless, agnóstico,
   sempre disponível — ADR-015). A superfície de UI opcional (componente Blade do editor e do
   visualizador) só aparece sob `/cms`, nunca em rota pública.
4. **Um renderer por nó/mark, concreto** (`Nodes/*`, `Marks/*`), com interfaces para DI — sem
   **traits**, sem **classes abstratas**, sem **genéricos** (ADR-001).
5. **Conjunto de extensões sóbrio** (o suficiente para publicação oficial):
   - **blocos:** parágrafo, títulos `h2`–`h4`, citação, lista com marcadores, lista numerada,
     linha horizontal, **tabela** (3×3 com header), **colunas**, **imagem/mídia** e **YouTube**;
   - **marcas:** negrito, itálico, sublinhado, tachado, marca-texto, **link** e "defasado";
   - **atributos:** alinhamento de texto e recuo.
6. **Imagens usam o serviço Media** (ADR-026): a inserção passa pelo fluxo único de ingestão e o
   portal renderiza `<picture>` (AVIF/WebP, múltiplas larguras) — **nunca** `<img>` cru.
7. **Links são `<a>` reais e acessíveis**: destino, `target`/`rel` (`noopener noreferrer`),
   `hreflang`, `title`/`aria-label` descritivos; o sistema **não** exibe URL crua.
8. **Link para conteúdo do portal** integra o **serviço Search**: modal **centralizado na view**,
   **sem overlay de página inteira**, com **popover** elegante de pré-visualização do destino.
9. **Descartados** os blocos especiais do `sucesu-cms` (**HTML livre**, FAQ/sanfona, carrossel,
   grade de cards, passo-a-passo, tabela interativa/filtrável, barra de comandos). Motivados por
   **simplicidade, segurança e acessibilidade**; o código de referência permanece comentado para
   evolução futura. Em especial, **não existe nó de HTML livre** (superfície de XSS).
10. **Segurança/CSP:** o bundle do editor é **externo e vendorizado**; **sem `unsafe-inline`**,
    **sem script inline de inicialização** (ADR-020/ADR-022). O conteúdo público é renderizado no
    servidor → HTML determinístico → `ETag`/`304` (ADR-001/ADR-022).
11. **Determinismo:** a renderização AST→HTML é **pura e ordenada** (sem `now()`/`rand()`); o mesmo
    AST produz sempre o mesmo HTML (base para `ETag` e testes sem flaky).
12. **i18n:** o AST é por locale (ADR-012); o renderer recebe o conteúdo do locale ativo.

### Estrutura prevista

```
src/services/RichText/
├── ContentASTParser.php             # percorre o AST (1 método por nó/mark) e devolve HTML/texto
├── Contracts/RichTextRenderer.php   # porta consumida pelos módulos (ADR-015)
├── Http/RichTextPreviewController.php       # preview autenticado do CMS (mesmo parser)
├── View/components/editor.blade.php # editor (form) — Blade opcional
├── Assets/js/                       # TipTap + schema/toolbar/dialogs/preview (vendored, Vite)
├── Assets/css/{editor,content}.css  # base do ProseMirror + classes `richtext-*` (sem inline)
└── ServiceProvider.php
```

### Exemplo mínimo (AST e render)

```json
// ✅ Salvo em jsonb — nunca HTML
{ "type": "doc", "content": [
  { "type": "heading", "attrs": { "level": 2 }, "content": [{ "type": "text", "text": "Título" }] },
  { "type": "paragraph", "content": [
      { "type": "text", "marks": [{ "type": "link", "attrs": { "href": "https://x.gov.br", "target": "_blank" } }],
        "text": "Saiba mais" } ] }
] }
```
```php
// ✅ Backend: AST → HTML semântico
$html = $richText->toHtml($noticia->content); // '<h2>Título</h2><p><a href="…" rel="noopener noreferrer">Saiba mais</a></p>'
// ❌ Proibido: guardar '<p>…</p>' no banco ou renderizar com {!! $raw !!} vindo do editor
```

## Emendas (implementação F4.I1.T2)

1. **§4 — renderer por nó/marca:** implementado como **um** `ContentASTParser` com **um método por
   nó/marca** (e um teste de contrato por nó) em vez de uma classe por nó: nenhuma classe precisa de
   estado próprio e só a mídia precisa de colaborador — injetado no parser (ADR-001, sem cerimônia).
   Classes voltam a se justificar quando um nó precisar de fila/estado próprio.
2. **§6 — imagem:** o nó `media` guarda a **chave do objeto** no disco `s3` + as variantes
   (`sources`) geradas pelo service Media; o parser resolve a URL pelo service Media
   (`media.stream`, mesma origem) e emite `<picture>` AVIF/WebP com `width`/`height` (**URL não
   entra no conteúdo** — a troca RustFS ↔ AWS/CDN é por `.env`, ADR-030).
3. **§5/§9 — YouTube:** entra como **link** (título + destino), **sem `iframe`**: a CSP do Portal não
   tem `frame-src` para o YouTube e a allowlist do ADR-002 é fechada (VLibras + Google Analytics).
   *Facade* click-to-load com consentimento (LGPD) exige **ADR própria**.
4. **§5 — alinhamento:** `justify` **descartado** (`left|center|right`); texto justificado é
   desaconselhado por eMAG/WCAG. `indent` é **passo** (0–4 → classe), nunca `px` no conteúdo.
5. **§8 — cross-link:** **não** existe nó `portalLink`: o link para conteúdo do portal é a marca
   `link` com caminho interno, e o locale vem do **invariante de URL** (ADR-012) — sem `switch` de
   modelo/rota dentro do parser (o defeito do `sucesu-cms` que o plano 18 registrou).
6. **§Verificação — exclusividade do editor:** a checagem citada aqui atribuía a
   `tools/name-check` o que ele não faz (aquele gate cuida do **token de módulo**, ADR-004). A
   verificação passou a ser **executável**: `src/services/RichText/Test/RichTextExclusivityTest`
   prova que nenhum editor concorrente (`filament`/`ckeditor`/`tinymce`/`quill`) aparece em
   `src`/`resources`/`config`/`tools` e que `@tiptap/` só existe dentro do serviço RichText
   (o `package.json` é o ponto de aquisição; o portal nunca carrega o editor).

## Justificativa (alternativas descartadas)

- **Manter o RichEditor do Filament** — descartada: mantém todo o peso do Filament (ADR-009).
- **CKEditor / TinyMCE / Quill** — descartadas: mais dependências, licenças/pesos e/ou CDN.
- **Armazenar HTML** — descartada: superfície de XSS, sem semântica garantida e fere ADR-017.
- **Somente Markdown** — descartada: não dá o controle de layout (tabela, colunas, mídia) exigido.
- **Manter todos os blocos especiais** — descartada: complexidade, a11y e manutenção altas.
- **Um editor por módulo** — descartada: fragmenta a experiência e a semântica do portal.

## Consequências

**Positivas**
- Um só editor, seguro (sem HTML no banco), acessível e com paridade CMS↔portal.
- Conteúdo determinístico (AST) → cache/ETag, resumos, Markdown e indexação triviais.
- Desacoplado do framework de UI: sobrevive à remoção do Filament e a futuras trocas.

**Custos / negativas**
- Duas representações a manter em sincronia (schema TipTap × renderer PHP).
- Requer build de JS (Vite) e um teste de contrato AST↔render por nó.

## Verificação

- `tools/name-check`: nenhuma referência a `filament`/`ckeditor`/`tinymce`/`quill`; `tiptap` só no
  bundle do editor.
- Teste: conteúdo salvo é JSON válido (`jsonb`); nenhum `<script>`/`<style>`/HTML cru no conteúdo.
- Teste de contrato: para cada nó/mark, `toHtml()` gera a tag semântica esperada (vermelho→verde).
- E2E: criar notícia com link, imagem e tabela → portal mostra `<a>` real, `<picture>` e `<table>`
  semântica; axe sem violações; editor opera por teclado.
- CSP sem `unsafe-inline`; bundle do editor servido do domínio.

## Referências

- `sucesu-cms/V2/Services/ContentRichEditor/` (Parser, Renderers, Views, Assets);
  `sucesu-cms/plan/18_richEditor_2026_08_17.md`; `wiki/plan/12-admin-cms-editor.md`.
