---
id: SVC-CONTENTRICHEDITOR
tipo: servico
onda: 2
status: concluido
unidades: [SVC-CONTENTRICHEDITOR]
fontes:
  - V2/Services/ContentRichEditor/
depende_de: [CORE-BOOT, CORE-AUTH, SVC-MEDIA, SVC-GALLERY, SVC-SEARCH]
consumidores:
  - MOD-PUBLICACOES-NOTICIAS
  - MOD-INSTITUCIONAL-SOBRENOS
  - MOD-INSTITUCIONAL-HISTORIA
  - MOD-CONEXOES-PARCEIROSINSTITUCIONAIS
  - MOD-CONEXOES-UNIDADEREGIONAL
  - MOD-CONEXOES-ASSOCIADOS
  - MOD-INSTITUCIONAL-ASSOCIESE
  - MOD-PUBLICACOES-TRABALHECONOSCO
  - MOD-PUBLICACOES-AGENDA
  - SVC-LANDINGSECTIONSEDITOR
criticidade: alta
dono: SvcCre
---

# Serviço ContentRichEditor

> **Alvo de reimplementação (ADR-028).** O desenho AST TipTap + parser/renderers PHP é
> adotado em `src/services/RichText` (AST em `jsonb`, HTML semântico no servidor). O que
> **não** se porta: nó de HTML livre, blocos especiais (FAQ, carrossel, cards, passos,
> tabela interativa, barra de comandos); o conjunto de 26 nós/7 marks não é copiado
> integralmente — vale o conjunto sóbrio da ADR-028. Imagens passam pelo serviço Media e
> saem em `<picture>`.
> Ver [`pilha-alvo-public-v7.md`](../../09_reimplementacao/pilha-alvo-public-v7.md) §4.

> Companheiros deste documento: [ast-e-renderers.md](ast-e-renderers.md) (mapa de nós/marks e
> contratos de renderer) e [ui-contrato.md](ui-contrato.md) (Blade, DOM, eventos, toolbar,
> modais, split-screen e acessibilidade).

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | O autor monta um documento em HTML editável (Tiptap) e o serviço persiste esse documento como **JSON AST** no input do formulário; o portal converte o mesmo JSON em HTML estilizado, ou em Markdown, por um parser PHP recursivo. |
| Entrada | `@include('v2-service-contentricheditor::components.forms.content-rich-editor', ['name','label','value','required','assets_only'])`; JSON AST salvo (`text`/`jsonb`); `POST /cms/v2/editor/upload` multipart; `GET /cms/v2/editor/search?q=`; mensagem `postMessage` `contentRichPreviewUpdate` para o iframe de preview. |
| Saída | String JSON Tiptap gravada em `<input type="hidden" name="<name>">`; HTML semântico (`toHtml`) ou Markdown (`toMarkdown`); card de resumo dinâmico (view `iframe-summary`); JSON de upload (`success`,`path`,`alt`,`metadata`) e de busca (`id`,`model`,`title`,`description`,`slug`,`label`). |
| Efeitos colaterais | Upload grava arquivo no disco `public` via `MediaInputer` (com variantes WebP/cover de PDF); log de erro em upload, busca e render; nenhuma escrita em banco pelo serviço; nenhum cache. |
| Particularidade central | O editor e o renderizador são **dois mundos independentes**: o schema Tiptap (JS) e o registry de renderers (PHP) precisam ser mantidos em sincronia manual; divergências silenciosas descartam atributos (ex.: `bgColor` do CTA) ou não renderizam nós (`codeBlock`, `hardBreak`, mark `code`). |
| Como se integra | Namespace de views `v2-service-contentricheditor` (provider deriva de `strtolower(basename($servicePath))`), controller `V2\Services\ContentRichEditor\Controllers\ContentRichEditorController`, serviço `ContentRichEditorRendererService::toHtml()/toMarkdown()`, bundle `public/assets/js/content-rich-editor-bundle.js`. |
| Se removido, quebra | Formulários de Notícias, SobreNos (biografia), História, Parceiros, Unidade Regional, Associados/Instituições, AssocieSe (planos), Trabalhe Conosco; preview de Notícias e História; os 11 renders de LandingSectionsEditor; cards de resumo dinâmicos em qualquer conteúdo salvo. |
| Superfície pública | `ContentRichEditorRendererService::toHtml($ast): string`, `::toMarkdown($ast): string`; `ContentASTParser::parse()`, `renderNode()`, `renderMark()`, `addNodeRenderer()`, `addMarkRenderer()`; `ContentRichLinkRenderer::urlFor($model,$slug,$id)`; `RendererStrategyInterface::render()`; interfaces `NodeRendererInterface`/`MarkRendererInterface`; views e bundle JS; 2 rotas CMS. |

## 1. Efeito

### 1.1 Transformação que o serviço realiza

**Autoria.** O componente renderiza um wrapper com toolbar, uma área `contenteditable` do
ProseMirror e um `<input type="hidden">` com o mesmo `name` do campo. A cada transação o
editor reescreve o input com `JSON.stringify(editor.getJSON())`, de modo que o submit do
formulário envia a AST como texto
(`V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:42-49`,
`V2/Services/ContentRichEditor/Views/components/forms/content-rich-editor.blade.php:16`).

**Renderização.** `ContentRichEditorRendererService` normaliza a entrada (string JSON ou
array) e delega a uma estratégia: `HtmlStrategy` → `ContentASTParser` (registry de closures
por `type`) → HTML; `MarkdownStrategy` → parser próprio com renderers sobrescritos
(`V2/Services/ContentRichEditor/Services/ContentRichEditorRendererService.php:14-35`).

**Resumo dinâmico.** `contentRichIframe` não guarda HTML: guarda `model` + `id` e o PHP consulta
`v2_public_search_index` no momento da renderização para montar o card
(`V2/Services/ContentRichEditor/Parser/Nodes/ContentRichIframeRenderer.php:24-30`,
`V2/Services/ContentRichEditor/Views/components/iframe-summary.blade.php:12-24`).

### 1.2 Efeitos sobre dados e sistema

| Efeito | Recurso afetado | Ponto no código | Fonte |
|---|---|---|---|
| Grava AST JSON no input do formulário a cada edição | DOM `<input type="hidden">` do formulário hospedeiro | `onUpdate` do `Editor` | `V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:42-49` |
| Publica HTML + JSON no iframe de live preview (debounce 800 ms) | `iframe.contentWindow` (`preview-iframe`/`live-preview-iframe`) | `postMessage({type:'contentRichPreviewUpdate',html,content})` | `V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:52-74` |
| Dispara evento global `lotep:open-gallery-picker-global-media-picker` | Galeria V2 (picker global do layout CMS) | callback insere `contentRichMedia` | `V2/Services/ContentRichEditor/Assets/js/modals.js:229-244` |
| Upload grava arquivo e variantes | disco `public` (`editor_uploads/`) via `MediaInputer::processUpload` | `ContentRichEditorController@uploadMedia` | `V2/Services/ContentRichEditor/Controllers/ContentRichEditorController.php:26-55` |
| Log de erro em falha de upload/busca/render | canal default (`Log::error`), `Log::warning` no card de iframe | sem transação, sem rollback | `V2/Services/ContentRichEditor/Controllers/ContentRichEditorController.php:52,92`; `V2/Services/ContentRichEditor/Views/components/iframe-summary.blade.php:26-28` |
| Nenhuma escrita em banco | — | o serviço só lê `v2_public_search_index` | `V2/Services/ContentRichEditor/Controllers/ContentRichEditorController.php:70-74` |
| Nenhum cache | — | parser instanciado por chamada | `V2/Services/ContentRichEditor/Services/ContentRichEditorRendererService.php:17,24` |

### 1.3 Contrato observável

| Entrada | Resultado |
|---|---|
| `toHtml('{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Oi"}]}]}')` | `"<p>Oi</p>"` |
| `toHtml([])` / `toHtml('')` / `toHtml('texto solto')` / JSON de raiz diferente de `doc` | `""` (string vazia), sem exceção (`V2/Services/ContentRichEditor/Parser/ContentASTParser.php:35-41`; `V2/Services/ContentRichEditor/Services/ContentRichEditorRendererService.php:28-35`) |
| `toHtml($arrayAst)` (ex.: cast `array` de `jsonb`) | mesmo resultado da string equivalente (`V2/Services/ContentRichEditor/Services/ContentRichEditorRendererService.php:28-35`) |
| AST com `type` desconhecido | concatena o conteúdo dos filhos, aplica marks e descarta a tag do nó (`V2/Services/ContentRichEditor/Parser/ContentASTParser.php:68-72`) |
| `POST /cms/v2/editor/upload` válido | `200 {"success":true,"path":"editor_uploads/media_<uniqid>_<ts>.<ext>","alt":"...","metadata":{...}}` (o `path` é o **original**; a tentativa de usar `variants["webp"]` nunca casa — ver §3.5) |
| Upload > 10 MB ou mime fora da lista | `422` JSON de validação do Laravel (o JS só mostra "Falha na comunicação com o servidor.") |
| Falha interna no upload | `500 {"success":false,"message":"Falha interna ao processar arquivo."}` |
| `GET /cms/v2/editor/search?q=a` | `200 []` (menos de 2 caracteres) |
| `GET /cms/v2/editor/search?q=not` | `200` lista de até 10 objetos com `id`,`model`,`title`,`description`,`slug`,`label` |
| Erro de banco na busca | `200 []` (fail-open silencioso) |
| Usuário não autenticado em qualquer rota | `401 Unauthorized.` (AJAX/JSON) ou redirect `v2.cms.login` |
| Usuário autenticado sem role | `403 Forbidden. No administrative roles assigned.` |

### 1.4 O que o serviço deliberadamente NÃO faz

- **Não valida a AST no servidor.** O campo chega como `nullable|string` nos módulos
  (`V2/Modules/Publicacoes/Noticias/Requests/NewsRequest.php:34`); qualquer string é aceita e
  gravada. O parser apenas devolve `""` quando não entende.
- **Não autoriza por módulo.** As rotas usam `CmsAuthenticate`, mas `/cms/v2/editor/*` não
  casa com nenhum `active_pattern` de módulo, então `V2PermissionMiddleware` libera qualquer
  usuário autenticado com role (`V2/Core/Middleware/V2PermissionMiddleware.php:72-76`).
- **Não sanitiza HTML livre.** `contentRichHtmlFree` devolve `attrs.html` cru
  (`V2/Services/ContentRichEditor/Parser/Nodes/ContentRichHtmlFreeRenderer.php:16-20`); respostas de FAQ, texto de
  slide e `tableTitle` também não são escapados.
- **Não migra conteúdo legado no banco.** A conversão de HTML antigo para AST acontece no
  cliente, na primeira edição (`V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:24-36`), ou nunca: cada
  módulo decide o fallback de exibição.
- **Não indexa nem invalida cache.** O serviço apenas lê o índice de busca já populado pelos
  modelos dos módulos.
- **Não destrói instâncias.** Não existe `destroy()`/teardown no componente; remover o DOM
  deixa o editor e `window['lotepEditorInstance_<name>']` vivos.

### 1.5 Efeito por perfil

| Perfil | Efeito |
|---|---|
| Público do portal | Recebe HTML já renderizado (sem JS do editor); nunca vê JSON. Precisa do bundle? Não: os partials do viewer só injetam CSS (`viewer-scripts` é vazio). |
| CMS (autor) | Edita WYSIWYG, insere mídia pela Galeria, busca registros no índice público, e vê preview em iframe quando o módulo implementa o listener. |
| CMS (read-only) | Consegue abrir o formulário (o editor não checa permissão), mas o submit é barrado pelo módulo hospedeiro; as rotas de upload/busca continuam acessíveis. |
| API | Não exposta: nenhuma rota `api.php`. |
| Operador regional | Mesmo comportamento do autor; o escopo regional é aplicado pelo módulo (ex.: `regionalScope` em Notícias), não pelo editor. |

## 2. Abordagem

### 2.1 Estratégia e por quê

Três decisões explícitas no código:

1. **AST como formato de persistência**, não HTML. O editor serializa JSON e o PHP renderiza;
   o comentário do parser justifica "evitamos dependências externas de pacotes Tiptap,
   garantindo segurança no PHP 7.2" (`V2/Services/ContentRichEditor/Parser/ContentASTParser.php:8-12`). O resultado é um
   interpretador recursivo *caseiro* em vez de usar `@tiptap/html` no servidor.
2. **Registry de renderers por tipo** (`$nodeRenderers`/`$markRenderers`) com closures que
   instanciam classes `Nodes\*`/`Marks\*`; permite sobrescrever tipo sem tocar no traversal
   (`V2/Services/ContentRichEditor/Parser/ContentASTParser.php:98-106`). `MarkdownStrategy` usa exatamente esse gancho
   para trocar parte dos renderers, mantendo o mesmo traversal
   (`V2/Services/ContentRichEditor/Renderers/MarkdownStrategy.php:19-23`).
3. **Strategy no topo** (`HtmlStrategy`/`MarkdownStrategy` implementando
   `RendererStrategyInterface`), com o serviço como fachada que só normaliza a entrada
   (`V2/Services/ContentRichEditor/Renderers/Contracts/RendererStrategyInterface.php:10-17`).

### 2.2 Mecânica passo a passo

**A. Autoria (browser)**

1. O Blade imprime `wrapper-<name>`, `input-<name>` (hidden, com `old($name,$value)`),
   `toolbar-<name>`, `editor-<name>` e, uma única vez por request, os modais e o bundle
   (`V2/Services/ContentRichEditor/Views/components/forms/content-rich-editor.blade.php:10-22,210,217-221,424-426`).
2. `initContentRichEditors(container)` varre `.content-rich-editor-wrapper`, ignora quem tem
   `dataset.initialized`, resolve os três IDs e monta as extensões
   (`V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:2-21`).
3. Lê `input.value`; se for JSON, desembrulha `contentRichSection` legado; se não for JSON,
   passa a string crua para o Tiptap interpretar como HTML
   (`V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:24-36`).
4. Instancia `Editor` com `onUpdate` (grava o input e publica no preview) e `onTransaction`
   (estado dos botões) (`V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:38-81`).
5. Cliques na toolbar sobem por delegação até `button[data-command], a[data-command]` e vão
   para `lotepEditorHandleCommand(editor, cmd, btn, wrapper)`
   (`V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:84-94`; switch completo em
   `V2/Services/ContentRichEditor/Assets/js/logic/editor-commands.js:2-292`).
6. Comandos de mídia abrem a Galeria; comandos de link/iframe abrem o modal de busca; os
   demais usam `lotepEditorPrompt`/`lotepEditorComplexPrompt` (modais jQuery dinâmicos). Com o
   Bootstrap 5 do CMS, os caminhos que chamam `$(...).modal(...)` falham — ver §3.5.

**B. Persistência**

7. O formulário envia o input hidden como string. O controller do módulo grava direto
   (ex.: `$news->content = $data['content'] ?? null;`
   `V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:256`).

**C. Renderização**

8. A view/controller chama `toHtml($ast)` (string JSON, array PHP ou `null`).
9. `ensureArray()` decodifica string, aceita array, devolve `[]` em qualquer outro caso
   (`V2/Services/ContentRichEditor/Services/ContentRichEditorRendererService.php:28-35`).
10. `HtmlStrategy::render()` chama `ContentASTParser::parse()`; `parse()` exige raiz `doc`
    (`V2/Services/ContentRichEditor/Parser/ContentASTParser.php:34-41`).
11. `renderNode()` desce `content[]`; em nó `text` escapa com `htmlspecialchars(ENT_QUOTES)`;
    depois aplica as marks e, por último, o renderer do tipo
    (`V2/Services/ContentRichEditor/Parser/ContentASTParser.php:49-72`).
12. O HTML retornado é injetado sem escape pela view consumidora
    (`V2/Services/ContentRichEditor/Views/components/partials/viewer-body.blade.php:2`).

```mermaid
flowchart LR
  A[Blade form] -->|hidden input JSON| B[(coluna text/jsonb)]
  B --> C{toHtml/toMarkdown}
  C --> D[ensureArray]
  D --> E[HtmlStrategy]
  E --> F[ContentASTParser.parse]
  F --> G{renderNode recursivo}
  G -->|text| H[htmlspecialchars]
  G -->|marks| I[renderMark]
  G -->|node| J[registry Nodes\\*]
  J --> K[HTML]
  K --> L[view consumidora: {!! !!}]
  C2[MarkdownStrategy] --> F
```

### 2.3 Trechos de código pivô

```php
// V2/Services/ContentRichEditor/Parser/ContentASTParser.php:49-61 — descida recursiva:
// o conteúdo dos filhos é concatenado antes de qualquer decisão sobre o nó.
public function renderNode(array $node): string
{
    $type = $node['type'] ?? 'text';
    $content = '';

    if (isset($node['content']) && is_array($node['content'])) {
        foreach ($node['content'] as $child) {
            $content .= $this->renderNode($child);
        }
    } elseif ($type === 'text') {
        $content = htmlspecialchars($node['text'] ?? '', ENT_QUOTES, 'UTF-8');
    }
```

```php
// V2/Services/ContentRichEditor/Parser/ContentASTParser.php:63-72 — marcas e dispatch:
// a última mark do array é a mais externa; tipo sem renderer devolve o conteúdo.
    if (isset($node['marks']) && is_array($node['marks'])) {
        foreach ($node['marks'] as $mark) {
            $content = $this->renderMark($mark, $content);
        }
    }

    if (isset($this->nodeRenderers[$type])) {
        return call_user_func($this->nodeRenderers[$type], $node, $content);
    }

    return $content; // Fallback se não souber renderizar o nó
}
```

O parser é *bottom-up*: o nó só recebe a string já renderizada dos filhos. Isso explica por
que nós `atom` (mídia, FAQ, CTA, tabela interativa) ignoram `$content` e leem apenas `attrs`.
A ordem das marks é a do array (`bold` antes de `link` produz `<a><strong>…</strong></a>`).

```php
// V2/Services/ContentRichEditor/Parser/ContentASTParser.php:98-106 — extensão do registry:
// qualquer tipo pode ser sobrescrito depois da construção, sem herança.
public function addNodeRenderer(string $type, callable $callable)
{
    $this->nodeRenderers[$type] = $callable;
}

public function addMarkRenderer(string $type, callable $callable)
{
    $this->markRenderers[$type] = $callable;
}
```

`MarkdownStrategy` usa o gancho no construtor (`V2/Services/ContentRichEditor/Renderers/MarkdownStrategy.php:19-23`) e
substitui `paragraph`, `heading`, `bulletList`, `listItem`, `bold`, `italic`, `strike`,
`outdated`, `contentRichMedia`, `contentRichIframe`, `contentRichLink`
(`V2/Services/ContentRichEditor/Renderers/MarkdownStrategy.php:25-77`). O que não é sobrescrito continua saindo como
HTML.

```php
// V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:42-50 — a serialização
// canônica: cada transação substitui o valor do input pelo JSON completo.
onUpdate: ({ editor }) => {
    // Expõe o HTML para preview externo
    if (window.__lotepEditorLastHtml === undefined) {
        window.__lotepEditorLastHtml = editor.getHTML();
    }
    window.__lotepEditorLastHtml = editor.getHTML();
    const json = editor.getJSON();
    input.value = JSON.stringify(json);
```

Não há `debounce` na gravação: o input é reescrito a cada tecla; o `getHTML()` também é
recalculado a cada transação (custo O(documento)). O `postMessage` externo sim é debounced.

```php
// V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:52-74 — live preview:
// prefere o hook do módulo; senão usa postMessage direto no iframe.
if (typeof window.lotepEditorPreviewPostMessage === 'function') {
    window.lotepEditorPreviewPostMessage(json);
} else {
    const iframe = document.getElementById('preview-iframe') || document.getElementById('live-preview-iframe');
    if (iframe && iframe.contentWindow) {
        // ...
        iframe._lotepDebounce = setTimeout(() => {
            iframe.contentWindow.postMessage({
                type: 'contentRichPreviewUpdate',
                html: editor.getHTML(),
                content: json
            }, '*');
        }, 800);
    }
}
```

O módulo de Notícias implementa o hook e consome `window.__lotepEditorLastHtml`
(`V2/Modules/Publicacoes/Noticias/Views/cms/includes/scripts.blade.php:273-282`); o iframe
escuta `event.data.type === 'contentRichPreviewUpdate'` e troca `innerHTML` de
`#preview-target` (`V2/Modules/Publicacoes/Noticias/Views/cms/preview-iframe.blade.php:269-277`).

```php
// V2/Services/ContentRichEditor/Controllers/ContentRichEditorController.php:26-55 — upload
// síncrono: valida, delega ao MediaInputer e tenta trocar o path pela variante "webp"
// (branch morto: generateVariants() nunca usa essa chave).
public function uploadMedia(Request $request)
{
    $request->validate([
        'file' => 'required|file|max:10240|mimes:jpg,jpeg,png,gif,webp,svg,bmp,tiff,tif,pdf,mp3,wav,ogg,m4a,aac,webm,wma,mp4,mpeg,xlsx,xls,csv,ods',
    ]);
    // ...
    $result = $this->mediaInputer->processUpload($file, 'editor_uploads', 'public');
    $path = $result['path'];
    if (!empty($result['variants']) && isset($result['variants']['webp'])) {
        $path = $result['variants']['webp'];
    }
    return response()->json([
        'success' => true,
        'path' => $path,
        'alt' => $request->input('alt', ''),
        'metadata' => $result
    ]);
```

O parser recompõe `/storage/` no render, então `path` pode vir relativo; a Galeria devolve
`/storage/...` e o nó remove o prefixo (`V2/Services/ContentRichEditor/Assets/js/modals.js:232-239`).

```php
// V2/Services/ContentRichEditor/Parser/Nodes/ContentRichIframeRenderer.php:16-30 — o único nó
// que resolve dados em tempo de renderização, via view Blade.
if (empty($attrs['model']) || empty($attrs['id'])) {
    return '';
}

return view('v2-service-contentricheditor::components.iframe-summary', [
    'model' => $attrs['model'],
    'id'    => $attrs['id'],
    'slug'  => $attrs['slug'] ?? null,
])->render();
```

### 2.4 Algoritmos e custos

- **Traversal:** O(nós + marks) por render, com concatenação de string O(total de saída).
  Sem cache: cada chamada de `toHtml` cria `HtmlStrategy` + `ContentASTParser` e registra 33
  closures (26 nós + 7 marks) (`V2/Services/ContentRichEditor/Services/ContentRichEditorRendererService.php:17`; `V2/Services/ContentRichEditor/Parser/ContentASTParser.php:119-256`).
- **N+1 deliberado:** cada `contentRichIframe` executa um `SELECT` em
  `v2_public_search_index` (`V2/Services/ContentRichEditor/Views/components/iframe-summary.blade.php:12-16`). Uma
  notícia com 10 cards gera 10 queries no portal.
- **Busca:** `ILIKE '%term%'` em `title` e `search_deep_content`, sem uso do `search_vector`
  GIN, com `limit(10)` — full scan em tabela de índice público
  (`V2/Services/ContentRichEditor/Controllers/ContentRichEditorController.php:71-75`).
- **Upload:** leitura integral em memória (`file_get_contents`) + otimização de imagem/PDF
  dentro da request (`V2/Services/Media/Services/MediaInputer.php:245`); limite 10 MB.
- **IDs voláteis:** `uniqid()` (FAQ, carrossel, tabela) gera `id`/`aria-controls` diferentes a
  cada render; `str_random(9)` no FAQ depende do helper do Laravel 5.5
  (`V2/Services/ContentRichEditor/Parser/Nodes/ContentRichFaqRenderer.php:25`).

## 3. Particularidades

### 3.1 O que é exclusivo deste serviço

- **Única fonte de verdade do AST de conteúdo rico.** Nenhum outro serviço interpreta o JSON
  Tiptap; Gallery/Media fornecem arquivos, Search fornece registros, mas a árvore é domínio
  exclusivo daqui.
- **Schema duplicado em dois lugares.** O contrato real é a interseção entre
  `Assets/js/nodes/*.js` (`addAttributes`) e `Parser/Nodes/*Renderer.php` (chaves lidas). Não
  existe validação que garanta a paridade.
- **Cross-link com resolução de rota hardcoded.** `ContentRichLinkRenderer::urlFor()` é a
  única tabela que traduz `model` do índice de busca para URL pública
  (`V2/Services/ContentRichEditor/Parser/Nodes/ContentRichLinkRenderer.php:60-85`) — duplica conhecimento de rotas dos
  módulos.
- **Comparação:** `SVC-LANDINGSECTIONSEDITOR` reutiliza este editor para os campos de texto
  das seções e este serviço para renderizar cada texto; `SVC-MEDIA` é o dono do armazenamento,
  não do conteúdo.

### 3.2 Invariantes e regras não óbvias

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| RN-CONTENTRICHEDITOR-01 | A raiz da AST precisa ser `type: "doc"`; qualquer outra raiz renderiza `""`. | JSON legado de seção única não renderiza até ser migrado. | `V2/Services/ContentRichEditor/Parser/ContentASTParser.php:35-41` |
| RN-CONTENTRICHEDITOR-02 | Texto é sempre escapado com `ENT_QUOTES` **antes** das marks. | Marks não podem injetar HTML, mas também não recebem texto cru. | `V2/Services/ContentRichEditor/Parser/ContentASTParser.php:59` |
| RN-CONTENTRICHEDITOR-03 | Marks são aplicadas na ordem do array `marks`. | `bold` + `link` produzem `<a><strong>…</strong></a>` ou o inverso conforme a ordem. | `V2/Services/ContentRichEditor/Parser/ContentASTParser.php:61-66` |
| RN-CONTENTRICHEDITOR-04 | Nó sem renderer é "transparente": filhos são preservados, tag é descartada. | `codeBlock` sai como texto concatenado, sem `<pre>`. | `V2/Services/ContentRichEditor/Parser/ContentASTParser.php:68-72` |
| RN-CONTENTRICHEDITOR-05 | `heading` só aceita níveis 2, 3 e 4 no editor. | H1 nunca é gerado pelo conteúdo; o H1 é do layout. | `V2/Services/ContentRichEditor/Assets/js/logic/editor-extensions.js:58-62` |
| RN-CONTENTRICHEDITOR-06 | `contentRichColumns` exige exatamente 2 `contentRichColumn`. | Inserir 1 ou 3 colunas é inválido no schema. | `V2/Services/ContentRichEditor/Assets/js/nodes/content-rich-columns.js:4` |
| RN-CONTENTRICHEDITOR-07 | `contentRichMedia` normaliza `path` para `/storage/<path>` quando ausente. | Path relativo e absoluto funcionam; `//cdn` viraria `/storage//cdn`. | `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichMediaRenderer.php:30-33` |
| RN-CONTENTRICHEDITOR-08 | Se `alt` parece nome de arquivo, ele não vira `alt` nem legenda. | Evita "foto.jpg" como texto alternativo. | `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichMediaRenderer.php:57-59,87-88` |
| RN-CONTENTRICHEDITOR-09 | `contentRichIframe` e `contentRichLink` sem `model` (ou sem `id`/`slug`) renderizam vazio. | Cards/links órfãos desaparecem silenciosamente. | `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichIframeRenderer.php:22-24`; `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichLinkRenderer.php:20-22` |
| RN-CONTENTRICHEDITOR-10 | FAQ/carrossel/tabela retornam `''` quando a lista está vazia. | Bloco vazio não deixa container fantasma. | `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichFaqRenderer.php:22-24`; `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichCarouselRenderer.php:24-26` |
| RN-CONTENTRICHEDITOR-11 | `assets_only=true` suprime wrapper/toolbar mas mantém modais + CSS/JS. | Permite carregar o bundle uma vez e usar N editores. | `V2/Services/ContentRichEditor/Views/components/forms/content-rich-editor.blade.php:6,9,214,217-221` |
| RN-CONTENTRICHEDITOR-12 | O bundle é o único jeito suportado de carregar o editor | `@push('scripts')` com `type="module"`; sem bundle, a toolbar não responde. | `V2/Services/ContentRichEditor/Views/components/forms/content-rich-editor.blade.php:424-426` |
| RN-CONTENTRICHEDITOR-13 | IDs de DOM derivam do `name` literal, inclusive colchetes. | `name=parameters[items][0][text]` → `input-parameters[items][0][text]`; funciona com `getElementById`, mas não com seletores CSS sem escape. | `V2/Services/ContentRichEditor/Views/components/forms/content-rich-editor.blade.php:10-22` |
| RN-CONTENTRICHEDITOR-14 | Um wrapper só inicializa uma vez (`dataset.initialized`). | Reinit após clonar exige limpar `data-initialized` e os IDs (helper do LSE). | `V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:10-11` |

### 3.3 Casos-limite

| Caso | Comportamento |
|---|---|
| AST `null`/vazia | `toHtml` devolve `""`; `content-rich-viewer` não imprime **nada** (nem CSS) (`V2/Services/ContentRichEditor/Views/components/content-rich-viewer.blade.php:19`). |
| String não-JSON no campo | `ensureArray` devolve `[]` → `""`; a view consumidora decide o fallback (parágrafo escapado, resumo etc.). |
| JSON válido com raiz `"contentRichSection"` | Render vazio até o editor abrir e desembrulhar (`V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:26-33`). |
| JSON corrompido no editor | `JSON.parse` falha e a string crua é entregue ao Tiptap, que tenta interpretar como HTML (`V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:34-36`). |
| `contentRichMedia` sem `path` | `""` (`V2/Services/ContentRichEditor/Parser/Nodes/ContentRichMediaRenderer.php:23-25`). |
| `contentRichMedia` PDF com `alt` contendo aspa simples | `e()` não protege contexto JS de `onclick`; o atributo é decodificado antes do parse JS (`V2/Services/ContentRichEditor/Parser/Nodes/ContentRichMediaRenderer.php:39`). `[I]` vetor de injeção restrito a autor. |
| `contentRichHtmlFree` com `<script>` | Sai cru; depende do CSP (nonce global em `app/Http/Kernel.php:20`) e da permissão do autor. |
| Carrossel sem `backgroundImageUrl` | Sem `style` de imagem; o CSS do viewer pinta fundo via `--tenant-primary` (`V2/Services/ContentRichEditor/Parser/Nodes/ContentRichCarouselRenderer.php:60-63`). |
| Tabela interativa com TSV vazio | `fgetcsv` devolve uma linha nula/`[null]`; cabeçalho e corpo vazios; script ainda é emitido. |
| Tabela interativa com `tableWidth` injetado | `tableWidth` entra cru no `style` (`V2/Services/ContentRichEditor/Parser/Nodes/ContentRichInteractiveTableRenderer.php:25,106`). `[I]` injeção de CSS. |
| Busca com 1 caractere | Curto-circuito `[]` antes da query (`V2/Services/ContentRichEditor/Controllers/ContentRichEditorController.php:63-66`). |
| Registro removido do índice após inserir o card | Card cai no fallback "Conteúdo relacionado" com `href="#"` (`V2/Services/ContentRichEditor/Views/components/iframe-summary.blade.php:6-10,18-24`). |
| Duas instâncias de editor na mesma página | Funcionam; cada uma tem `window['lotepEditorInstance_<name>']`, mas os modais são singletons e compartilham `activeEditorInstance` (`V2/Services/ContentRichEditor/Assets/js/modals.js:6,250`). |
| Nome com colchetes | IDs/atributos funcionam; `label[for]` aponta para o input hidden, não para a área editável. |

### 3.4 Armadilhas de reimplementação

1. **Ordem do bundle é contrato.** `_bundle-header.js` declara `ContentRichEditorDeps`,
   `activeEditorInstance`, `fixMediaRotation` e o listener `lotep:open-pdf`; `modals.js` usa
   `$` e `activeEditorInstance` no topo; os nós usam `Node`/`Mark` do escopo do módulo;
   `editor-extensions.js` só referencia os globais dentro da função; `content-rich-editor.js`
   é o último e executa `$(initContentRichEditors)` (`V2/Services/ContentRichEditor/Assets/build-bundle.sh:22-70`).
   Reordenar quebra por TDZ (`let activeEditorInstance`).
2. **O bundle não é auto-suficiente em CSS.** O layout A4, toolbar sticky, fullscreen e
   `.content-rich-media-align-*` vêm de `@push('css')` dentro do próprio Blade
   (`V2/Services/ContentRichEditor/Views/components/forms/content-rich-editor.blade.php:241-421`). O CSS do portal é
   outro (`viewer-styles`).
3. **Não existe prudência com conteúdo legado.** O fallback de HTML é por módulo; alguns
   fazem `json_decode` + escape (`UnidadeRegional`), outros passam a string direto ao
   renderer (que devolve `""`).
4. **O schema é a validação.** Atributo não declarado em `addAttributes()` é descartado pelo
   ProseMirror antes de chegar ao input — não adianta o comando enviar.
5. **Sanitização é seletiva, não global.** Nunca envolver `toHtml()` em `strip_tags` esperando
   segurança: `contentRichHtmlFree` e respostas de FAQ são HTML intencional.
6. **`contenteditable` convive com jQuery/Bootstrap 5.** Os modais do editor foram escritos
   para Bootstrap 3 (`$().modal`, `enforceFocus`), mas o layout carrega Bootstrap 5.3.2
   (`V2/layouts/cms/layouts/app.blade.php:101`); ver §3.5.
7. **A view do card consulta o banco.** Renderizar conteúdo em fila/CLI exige conexão de
   banco; `iframe-summary` não é puro.
8. **IDs aleatórios impedem cache de HTML íntegro por trecho.** FAQ/carrossel/tabela mudam de
   `id` a cada render.

### 3.5 Dívidas e divergências

| Item | Código | Documentação/README | Consequência |
|---|---|---|---|
| Autenticação das rotas | `CmsAuthenticate` (`V2/Services/ContentRichEditor/Routes/cms.php:9`) | `sentinel.auth` (`V2/Services/ContentRichEditor/Routes/README.md:18`) | O middleware real é o do V2 e chama `V2PermissionMiddleware`; sem permissão específica de editor. |
| Tabela de busca | `v2_public_search_index` (`V2/Services/ContentRichEditor/Controllers/ContentRichEditorController.php:70`) | `public_search_index` (`V2/Services/ContentRichEditor/Controllers/README.md:22`) | Nome documentado não existe. |
| Render de imagem | `<figure><img>` inline (`V2/Services/ContentRichEditor/Parser/Nodes/ContentRichMediaRenderer.php:84-90`) | "usa o `MediaViewer` do módulo Media" (`V2/Services/ContentRichEditor/Parser/README.md:41`) | Sem viewer/zoom no portal. |
| Versão de PHP | `composer.json` exige `>=7.4.0`; plataforma 7.4.33 | "PHP 7.2" (`V2/Services/ContentRichEditor/Parser/README.md:9`) | `str_random()` só existe no Laravel 5.5; em 6+ o FAQ quebra. |
| Toolbar | 2 linhas (`V2/Services/ContentRichEditor/Views/components/forms/content-rich-editor.blade.php:25,140`) | "uma única linha" (`V2/Services/ContentRichEditor/Views/README.md:12`) | Divergência visual documentada. |
| `@push` | `@push('scripts')` (`V2/Services/ContentRichEditor/Views/components/forms/content-rich-editor.blade.php:424`) | `@push('js')` (`V2/Services/ContentRichEditor/README.md:44`) | O layout renderiza ambos (`@stack('scripts')` e `@stack('js')`), então funciona, mas o README induz a erro. |
| Modal de mídia legado | markup sempre renderizado (`V2/Services/ContentRichEditor/Views/components/modals/media-modal.blade.php:1`), mas nenhum código chama `.modal('show')` nele; a toolbar usa a Galeria (`V2/Services/ContentRichEditor/Assets/js/modals.js:229-244`) | README descreve o fluxo de upload próprio | UI e endpoint `/upload` ficam órfãos no fluxo padrão. `[I]` (pode haver chamada externa). |
| jQuery + Bootstrap 5 | `$('#...').modal('show')` e `$.fn.modal.Constructor` (`V2/Services/ContentRichEditor/Assets/js/logic/editor-modals.js:33-36`; `V2/Services/ContentRichEditor/Assets/js/modals.js:248`) | README fala "Bootstrap 3" (`V2/Services/ContentRichEditor/Assets/README.md:19`) | Com BS 5.3.2 não há plugin jQuery: os prompts dinâmicos (FAQ/CTA/passos/cards/tabela/barra/carrossel/HTML/YouTube/link) lançam `TypeError` ao tentar abrir. `[C]` layout carrega só BS5. |
| CTA com cores | `addAttributes` só declara `text`/`url` (`V2/Services/ContentRichEditor/Assets/js/nodes/content-rich-cta-button.js:6-11`), mas o comando envia `bgColor`/`textColor` (`V2/Services/ContentRichEditor/Assets/js/logic/editor-commands.js:133-134`) e o PHP lê ambos (`V2/Services/ContentRichEditor/Parser/Nodes/ContentRichCtaButtonRenderer.php:20-23`) | — | Cores escolhidas nunca persistem; saem os defaults. |
| `contentRichIframe.slug` | Schema só tem `model`/`id` (`V2/Services/ContentRichEditor/Assets/js/nodes/content-rich-iframe-summary.js:7-12`); o renderer lê `slug` | — | `slug` só existe se o JSON vier de fora; o editor o descarta. |
| Carrossel CSS x markup | Renderer emite classes BS5 (`carousel-item`, `button.carousel-control-prev`) | `viewer-styles` estiliza `li`/`.item` BS3 (`V2/Services/ContentRichEditor/Views/components/partials/viewer-styles.blade.php:591-604`) | Indicadores/controles sem o estilo esperado. |
| Nó `contentRichSection` | Desembrulhado no browser (`V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:26-33`) | "content-rich-section.js [Obsoleto]" (`V2/Services/ContentRichEditor/Assets/README.md:37`) | O arquivo não existe no unit; a migração é inline. |
| Markdown | `MarkdownStrategy` incompleto (`V2/Services/ContentRichEditor/Renderers/MarkdownStrategy.php:25-77`) | README diz "útil para integrações externas" (`V2/Services/ContentRichEditor/Renderers/README.md:10`) | Nenhum consumidor encontrado; `orderedList`, `blockquote`, `underline`, `highlight`, `link` e nós ricos saem como HTML. |
| Doc de live preview | `@include('V2.Services...')` e prop `astContent` (`V2/Services/ContentRichEditor/LIVE_PREVIEW_IMPLEMENTATION.md:53`) | — | Namespace e prop errados; o correto é `v2-service-contentricheditor::...` e `ast`. |
| Bundle versionado | Fonte em `Assets/js`, gerado em `public/assets/js/content-rich-editor-bundle.js` (`V2/Services/ContentRichEditor/Assets/build-bundle.sh:18-20`) | `?v=vendor-tiptap` fixo (`V2/Services/ContentRichEditor/Views/components/forms/content-rich-editor.blade.php:425`) | Cache-busting manual; o bundle publicado não está no manifest do unit. |
| Variante WebP no upload | `$result['variants']['webp']` (`V2/Services/ContentRichEditor/Controllers/ContentRichEditorController.php:40-41`) | README promete "conversão para WebP" (`V2/Services/ContentRichEditor/Controllers/README.md:15-17`) | `ImageOptimizer::generateVariants()` devolve chaves por largura (`360`, `768`, …) ou `original`, nunca `webp` (`V2/Services/Media/Services/Midia/ImageOptimizer.php:79-99,117`). O `path` retornado é sempre o original. |

## 4. Integrabilidade

### 4.1 API pública

| Símbolo | Assinatura | Retorno | Exceções | Consumidores |
|---|---|---|---|---|
| `ContentRichEditorRendererService::toHtml` | `toHtml($ast): string` | HTML ou `""` | Não lança para entrada inválida (devolve `""`); pode propagar `TypeError` para filho não-array e exceções de DB no card de iframe | Todos os módulos de conteúdo e LSE |
| `ContentRichEditorRendererService::toMarkdown` | `toMarkdown($ast): string` | Markdown (parcial), `trim()` | idem | Nenhum uso encontrado no repositório (`grep toMarkdown`) |
| `ContentASTParser::parse` | `parse(array $ast): string` | HTML | — | `HtmlStrategy`, `MarkdownStrategy` |
| `ContentASTParser::renderNode` | `renderNode(array $node): string` | HTML | — | interno/extensão |
| `ContentASTParser::renderMark` | `renderMark(array $mark, string $content): string` | HTML | — | interno |
| `ContentASTParser::addNodeRenderer` | `addNodeRenderer(string $type, callable $callable)` | void | — | `MarkdownStrategy`, extensões |
| `ContentASTParser::addMarkRenderer` | `addMarkRenderer(string $type, callable $callable)` | void | — | `MarkdownStrategy` |
| `ContentRichLinkRenderer::urlFor` | `urlFor($model, $slug, $id): string` | URL relativa ou `""` | — | `V2/Services/ContentRichEditor/Views/components/iframe-summary.blade.php:24` |
| `ContentRichEditorController::uploadMedia` | `uploadMedia(Request): JsonResponse` | JSON | `ValidationException` (422) | bundle (modal legado) |
| `ContentRichEditorController::searchRecords` | `searchRecords(Request): JsonResponse` | JSON array | — | `V2/Services/ContentRichEditor/Assets/js/modals.js:154` |
| `ContentRichEditorController::__construct` | `__construct(MediaInputer $mediaInputer)` | — | — | container Laravel |

### 4.2 Componentes Blade e contrato de UI

Resumo (detalhe completo em [ui-contrato.md](ui-contrato.md)):

| Componente | Namespace | Props | Efeito |
|---|---|---|---|
| `components.forms.content-rich-editor` | `v2-service-contentricheditor` | `name` (default `content`), `label` (`Conteúdo`), `value` (`{"type":"doc","content":[]}`), `required` (false), `assets_only` (false) | Editor + toolbar + input hidden; injeta modais, CSS e bundle uma vez por request. |
| `components.content-rich-viewer` | idem | `ast` | Renderiza HTML + CSS do portal; não imprime nada se vazio. |
| `components.iframe-summary` | idem | `model`, `id`, `slug` | Card de conteúdo relacionado. |
| `components.layouts.split-screen` | idem | `form_action`, `form_method`, seções `editor_content`, `preview_url`, `top_content` | Layout de edição + preview. |
| `components.modals.media-modal` | idem | — | Modal de upload (não aberto pelo fluxo atual). |
| `components.modals.search-modal` | idem | — | Modal de busca de registros. |
| `components.partials.viewer-*` | idem | usa `$htmlContent` | Header/body/footer/styles/scripts do viewer. |

### 4.3 JS e ciclo de vida

- **Init:** `window.initContentRichEditors(container?)`, chamado em `$(function(){...})`
  (`V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:2,132-134`). Pode ser chamado de novo em subárvores
  (LSE/LandingSections: `V2/Services/LandingSectionsEditor/Views/forms/boxes.blade.php:118-120`).
- **Instância:** `window['lotepEditorInstance_' + name]` (`V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:97`).
- **Eventos DOM:** clique delegado em `[data-command]`; `load` capturado em `document` para
  corrigir rotação de imagem (`:119-129`); `lotep:open-pdf` em `document`
  (`V2/Services/ContentRichEditor/Assets/js/_bundle-header.js:85-89`); `lotep:open-gallery-picker-global-media-picker`
  em `window` (Galeria).
- **Estado:** input hidden (fonte da verdade), `window.__lotepEditorLastHtml`,
  `data-initialized`, `data-rotation`, `data-pdf-src`, `data-content-rich-media`.
- **Teardown:** inexistente. Não há `editor.destroy()`, remoção de listeners ou limpeza de
  `window[...]`.
- **Dependências:** Tiptap 2.0.0 vendorizado em `/assets/vendor/tiptap/tiptap-2.0.0.mjs`
  (ESM, sem CDN) (`V2/Services/ContentRichEditor/Assets/js/_bundle-header.js:7-11`); jQuery 3.6 e Bootstrap 5.3.2 do
  layout; Galeria para mídia.
- **Sem JS:** o input hidden permanece com o JSON inicial e o formulário envia o valor
  anterior; não há editor, toolbar nem preview. O portal não precisa de JS para exibir
  conteúdo, exceto o filtro da tabela interativa (script inline no HTML) e o accordion do FAQ
  (Bootstrap collapse).

### 4.4 Ordem de inicialização e registro

1. `V2ServiceProvider` varre `V2/Services`; para `ContentRichEditor` registra views no
   namespace `v2-service-contentricheditor` (derivado de `strtolower`) e carrega
   `Routes/cms.php` com middleware `web` + namespace de controller
   (`V2/Core/Providers/V2ServiceProvider.php:169-191,203-215`).
2. O layout CMS precisa ter jQuery, Bootstrap 5, o picker `global-media-picker` e os stacks
   `scripts`/`js` (`V2/layouts/cms/layouts/app.blade.php:100-139`).
3. O formulário inclui o componente; o CSS vai para `@push('css')` e o bundle para
   `@push('scripts')` (uma vez por request).
4. No browser: jQuery pronto → `initContentRichEditors()` → wrappers → `Editor`.
5. No servidor (render): o serviço pode ser resolvido por `app()` a qualquer momento; não há
   binding singleton nem facade.

### 4.5 Receitas de uso

**1. Campo no formulário CMS**

```blade
@include('v2-service-contentricheditor::components.forms.content-rich-editor', [
    'name'     => 'content',
    'label'    => '',
    'value'    => old('content', $news->content ?? '{"type":"doc","content":[]}'),
    'required' => false,
])
```

Resultado: `#wrapper-content` com toolbar; `#input-content` recebe o JSON a cada edição;
submit envia `content=<json>`. É exatamente o uso de
`V2/Modules/Publicacoes/Noticias/Views/cms/includes/_editor-content.blade.php:349-354`.

**2. Render no portal**

```php
/** @var \V2\Services\ContentRichEditor\Services\ContentRichEditorRendererService $renderer */
$renderer = app(\V2\Services\ContentRichEditor\Services\ContentRichEditorRendererService::class);
$html = $renderer->toHtml($news->content); // string JSON ou array
```

Uso de `V2/Modules/Publicacoes/Noticias/Controllers/NoticiasPortalController.php:101-103`.

**3. Render via componente (com CSS do viewer)**

```blade
@include('v2-service-contentricheditor::components.content-rich-viewer', ['ast' => $item->description])
```

Imprime `viewer-styles` + `div.content-rich-viewer` + HTML; nada quando `toHtml` devolve `""`
(`V2/Services/ContentRichEditor/Views/components/content-rich-viewer.blade.php:5-25`).

**4. Múltiplos editores com assets únicos (padrão LSE)**

```blade
@include('v2-service-contentricheditor::components.forms.content-rich-editor', ['assets_only' => true])

{{-- para cada seção/campo --}}
@include('v2-service-contentricheditor::components.forms.content-rich-editor', [
    'name'  => "parameters[items][$index][text]",
    'value' => $item['text'] ?? '',
    'label' => 'Texto',
])
```

O primeiro include injeta só modais/CSS/JS; os seguintes trazem wrapper e input, sem repetir
assets (`V2/Services/ContentRichEditor/Views/components/forms/content-rich-editor.blade.php:9,217-221`). Ao clonar um
bloco dinâmico, renomeie IDs e limpe `data-initialized` antes de
`initContentRichEditors(container)` (receita do LSE:
`V2/Services/LandingSectionsEditor/Views/components/forms/landing-sections-editor.blade.php:97-111`).

**5. Preview ao vivo**

```blade
@extends('v2-service-contentricheditor::components.layouts.split-screen')
@section('editor_content') ... @endsection
@section('preview_url') {{ route('v2.noticias.cms.preview-iframe', $news->id) }} @endsection
```

O iframe `#live-preview-iframe` recebe `postMessage`; a view alvo escuta e troca o
`innerHTML` de `#preview-target` (`V2/Services/ContentRichEditor/Views/components/layouts/split-screen.blade.php:91-99,111-121`).

### 4.6 Anti-uso

1. **Imprimir o campo cru.** `{{ $news->content }}` ou `{!! $news->content !!}` mostra JSON
   (ou, pior, um JSON interpretado como HTML). Sempre passar por `toHtml()`
   (`V2/Services/ContentRichEditor/README.md:82-84`).
2. **Criar nó só no PHP.** Adicionar `ContentRichFooRenderer` e registrar no parser sem criar
   `Assets/js/nodes/content-rich-foo.js` e a entrada em `editor-extensions.js` faz o nó nunca
   ser produzido; e criar só no JS faz o conteúdo sumir no portal (fallback transparente).
3. **Passar array para o prop `value`.** `value="{{ $array }}"` imprime "Array"/erro; o Blade
   espera string JSON. Use `json_encode()` (padrão de
   `V2/Modules/Institucional/SobreNos/Views/cms/members/_form.blade.php:54`).
4. **Reutilizar o componente sem o bundle já carregado.** Se a página não renderizar o layout
   CMS (ou o stack `scripts`), a toolbar aparece morta e o input nunca é atualizado.
5. **Colocar o editor dentro de modal que usa `enforceFocus`.** O código antigo desativa
   `enforceFocus` para evitar loop; com BS5 o problema muda de forma, mas a lição permanece
   (`V2/Services/ContentRichEditor/Assets/js/modals.js:247-248`).

### 4.7 Custos e limites

| Limite | Valor | Fonte |
|---|---|---|
| Upload | 10 MB (`max:10240`), 21 extensões | `V2/Services/ContentRichEditor/Controllers/ContentRichEditorController.php:27-29` |
| Busca | mínimo 2 caracteres; 10 resultados; sem throttle próprio | `V2/Services/ContentRichEditor/Controllers/ContentRichEditorController.php:63-65,75` |
| Preview | debounce 800 ms por iframe | `V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:61-72` |
| Editor | 1 instância por wrapper; sem limite de nós | `V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:9-11` |
| Render | O(nós) sem cache; 1 query por `contentRichIframe` | `V2/Services/ContentRichEditor/Parser/ContentASTParser.php:49-72`; `V2/Services/ContentRichEditor/Views/components/iframe-summary.blade.php:12-16` |
| Throttle | nenhum nas rotas | `V2/Services/ContentRichEditor/Routes/cms.php:9-14` |

## 5. Rotas e endpoints

| Método | URI | Nome | Middleware | Controller@ação | Permissão | Efeito | Fonte |
|---|---|---|---|---|---|---|---|
| grupo | `cms/v2/editor` | — | `web` (provider) + `CmsAuthenticate` (`Sentinel::check` + role) + `V2PermissionMiddleware` | — | nenhuma explícita (rota fora de `active_pattern` de módulo) | Prefixo do grupo; `@host v2.dominio.com/cms/v2/editor` | `V2/Services/ContentRichEditor/Routes/cms.php:9`; `V2/Core/Providers/V2ServiceProvider.php:212-215` |
| POST | `/cms/v2/editor/upload` | `v2.editor.upload` | idem | `ContentRichEditorController@uploadMedia` | autenticado com role | Valida e grava arquivo em `editor_uploads/`; devolve `{success,path,alt,metadata}` | `V2/Services/ContentRichEditor/Routes/cms.php:11`; `V2/Services/ContentRichEditor/Controllers/ContentRichEditorController.php:26-55` |
| GET | `/cms/v2/editor/search` | `v2.editor.search` | idem | `ContentRichEditorController@searchRecords` | autenticado com role | Consulta `v2_public_search_index` por `q` (ILIKE title/deep), 10 resultados formatados | `V2/Services/ContentRichEditor/Routes/cms.php:12`; `V2/Services/ContentRichEditor/Controllers/ContentRichEditorController.php:60-93` |

Parâmetros:

- `upload`: multipart `file` (obrigatório), `alt` (opcional), `_token` (CSRF). Resposta de
  validação é o padrão Laravel 5.5 para XHR (422 + `errors`).
- `search`: `q` (query string). Sem `q` ou `strlen < 2` → `[]`.
- Nenhuma paginação, ordenação ou filtro por módulo/tenant: a busca varre o índice público
  inteiro (`V2/Services/ContentRichEditor/Controllers/ContentRichEditorController.php:70-75`).

## 6. Dados e migrations

**O serviço não cria tabelas nem models.** Ele lê `v2_public_search_index` e grava arquivos
via `MediaInputer`. As colunas onde o JSON vive pertencem aos módulos:

| Consumidor | Coluna | Tipo | Cast | Migração |
|---|---|---|---|---|
| Notícias | `v2_news.content` | `text` | nenhum (`content` é string) | `V2/Modules/Publicacoes/Noticias/Migrations/2026_07_08_000001_create_v2_news_table.php:37` |
| SobreNos | `v2_board_members.bio` | `jsonb` | `array` | `V2/Modules/Institucional/SobreNos/Migrations/2026_07_20_000004_add_bio_and_slug_to_v2_board_members.php:19`; `V2/Modules/Institucional/SobreNos/Models/BoardMember.php:65-70` |
| Parceiros | `v2_parceiros.description` | `text` | — | `V2/Modules/Conexoes/ParceirosInstitucionais/Migrations/2024_01_01_000002_create_v2_parceiros_table.php:34` |
| Unidade Regional | `v2_unidade_regionais.description` | `text` | — | `V2/Modules/Conexoes/UnidadeRegional/Migrations/2026_07_23_000001_create_v2_unidade_regionais_table.php:22` |
| História | `v2_historia_milestones.description` | `text` | — | `V2/Modules/Institucional/Historia/Migrations/2026_07_20_000001_create_v2_historia_milestones_table.php:20` |
| AssocieSe | `v2_membership_plans.description` | `text` | — | `V2/Modules/Institucional/AssocieSe/Migrations/2025_01_01_000005_create_v2_membership_plans_table.php:26` |
| Associados | `v2_associado_instituicoes.description` | `text` | — | `V2/Modules/Conexoes/Associados/Migrations/2024_01_01_000004_create_v2_associado_instituicoes_table.php:22` |
| Trabalhe Conosco | `v2_job_openings.description_detailed` | `text` (a coluna legada `description` continua na tabela, mas o editor usa `description_detailed`) | — | `V2/Modules/Publicacoes/TrabalheConosco/Migrations/2026_08_11_000001_add_description_detailed_and_metadata_to_v2_job_openings.php:27` |
| Agenda | `v2_eventos.description` | `text` | — | `V2/Modules/Publicacoes/Agenda/Migrations/2024_01_01_000006_create_v2_eventos_table.php:26` |
| BasicPage — **não consumidor** (coluna homônima) | `v2_basic_pages.content` | `text` (HTML/CSS/JS, **não** AST) | — | `V2/Modules/Layouts/BasicPage/Migrations/2026_06_29_092300_create_basic_pages_table.php:28-29` |

### 6.1 Formato persistido (JSON Tiptap) — campo a campo

Estrutura genérica de nó:

| Campo | Tipo | Obrigatório | Significado |
|---|---|---|---|
| `type` | string | sim | Nome do nó/mark. Raiz `doc`. |
| `attrs` | object | não | Atributos do nó (tabela de nós abaixo). |
| `content` | array | não | Filhos (blocos ou inline, conforme o schema). |
| `marks` | array | não | Marks aplicadas a um nó `text`. |
| `text` | string | só em `text` | Conteúdo textual cru. |

Marks (7; todas sem atributos, exceto `link`):

| Mark | Atributos | Saída HTML | Markdown |
|---|---|---|---|
| `bold` | — | `<strong>` | `**x**` |
| `italic` | — | `<em>` | `*x*` |
| `strike` | — | `<s>` | `~~x~~` |
| `underline` | — | `<u>` | (não sobrescrito → `<u>`) |
| `highlight` | — | `<mark>` | (não sobrescrito → `<mark>`) |
| `outdated` | — | `<del class="text-muted content-rich-outdated" title="Conteúdo defasado">` | `~~x~~ (Defasado)` |
| `link` | `href`, `target?`, `rel?` (default `noopener noreferrer`) | `<a href …>` | (não sobrescrito → HTML) |

Exemplo mínimo completo:

```json
{
  "type": "doc",
  "content": [
    { "type": "paragraph", "content": [ { "type": "text", "text": "Olá" } ] }
  ]
}
```

→ `toHtml`: `<p>Olá</p>`.

Exemplo com todos os nós usados pelo serviço:

```json
{
  "type": "doc",
  "content": [
    { "type": "heading", "attrs": { "level": 2, "textAlign": "center", "indent": 0 },
      "content": [ { "type": "text", "text": "Título", "marks": [ { "type": "bold" } ] } ] },
    { "type": "paragraph", "attrs": { "textAlign": "justify", "indent": 20 },
      "content": [ { "type": "text", "text": "Link", "marks": [
        { "type": "link", "attrs": { "href": "https://exemplo.test", "target": "_blank" } },
        { "type": "underline" } ] } ] },
    { "type": "bulletList", "content": [
      { "type": "listItem", "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Item" } ] } ] } ] },
    { "type": "orderedList", "content": [
      { "type": "listItem", "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Primeiro" } ] } ] } ] },
    { "type": "blockquote", "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Citação" } ] } ] },
    { "type": "horizontalRule" },
    { "type": "table", "content": [
      { "type": "tableRow", "content": [
        { "type": "tableHeader", "attrs": { "colspan": 1, "rowspan": 1 },
          "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Coluna" } ] } ] } ] },
      { "type": "tableRow", "content": [
        { "type": "tableCell", "attrs": { "colspan": 1, "rowspan": 1 },
          "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Valor" } ] } ] } ] } ] },
    { "type": "contentRichMedia", "attrs": {
      "path": "editor_uploads/foto.webp", "type": "image", "variants": {},
      "alt": "Foto da equipe", "rotation": 90, "width": 50, "alignment": "left" } },
    { "type": "contentRichMedia", "attrs": {
      "path": "editor_uploads/edital.pdf", "type": "pdf", "variants": {},
      "alt": "Edital 01/2026", "rotation": 0, "width": 100, "alignment": "center" } },
    { "type": "contentRichIframe", "attrs": { "model": "noticias", "id": "uuid-da-noticia" } },
    { "type": "contentRichLink", "attrs": { "model": "agenda", "id": "uuid-do-evento", "slug": "evento-x", "title": "Ver evento" } },
    { "type": "contentRichColumns", "content": [
      { "type": "contentRichColumn", "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Esquerda" } ] } ] },
      { "type": "contentRichColumn", "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Direita" } ] } ] } ] },
    { "type": "contentRichFaq", "attrs": { "items": [
      { "question": "Pergunta?", "answer": "<p>Resposta com HTML</p>" } ] } },
    { "type": "contentRichCtaButton", "attrs": { "text": "Acessar", "url": "https://exemplo.test" } },
    { "type": "contentRichStepByStep", "attrs": { "steps": [
      { "title": "Passo 1", "description": "Descrição", "icon": "bi-info-circle" } ] } },
    { "type": "contentRichCards", "attrs": { "cards": [
      { "title": "Card", "description": "Descrição", "icon": "bi-file-earmark-text", "link": "/pagina" } ] } },
    { "type": "contentRichInteractiveTable", "attrs": {
      "title": "Tabela", "subtitle": "", "tableTitle": "Dados",
      "tableContent": "Nome\tValor\nA\t1", "showSearch": true,
      "textAlignment": "left", "tableWidth": "100%", "striped": true, "hasHeader": true } },
    { "type": "contentRichCommandLine", "attrs": {
      "leftButtons": [ { "text": "Baixar", "link": "/arquivo.pdf", "icon": "bi-download", "style": "padrao" } ],
      "rightButtons": [], "backgroundColor": "#ffffff" } },
    { "type": "contentRichCarousel", "attrs": {
      "title": "Destaques", "subtitle": "Novidades", "backgroundImageUrl": "",
      "items": [ { "icon": "bi-star", "title": "Slide 1", "text": "<p>Texto</p>" } ] } },
    { "type": "contentRichHtmlFree", "attrs": { "html": "<iframe src=\"https://exemplo.test\"></iframe>" } },
    { "type": "contentRichYoutube", "attrs": { "url": "https://www.youtube.com/watch?v=AAAAAAAAAAA" } }
  ]
}
```

Tabela de atributos por nó (o que o JS declara × o que o PHP lê):

| Nó | Atributos declarados no schema JS | Atributos lidos no PHP | Observação |
|---|---|---|---|
| `doc` | — (`content: 'block+'`) | — | raiz obrigatória |
| `paragraph` | `textAlign`, `indent` | `textAlign`, `indent` | style inline validado |
| `heading` | `level` (2–4), `textAlign`, `indent` | `level`, `textAlign`, `indent` | h2/h3/h4 |
| `bulletList`/`orderedList`/`listItem` | — | — | `<ul>/<ol>/<li>` |
| `blockquote`/`horizontalRule` | — | — | |
| `table`/`tableRow`/`tableCell`/`tableHeader` | `colspan`, `rowspan` (Table) | `colspan`, `rowspan` | `tableHeader` sempre `scope="col"` |
| `contentRichMedia` | `path`,`type`,`variants`,`alt`,`rotation`,`width`,`alignment` | os 7 | PDF ignora width/alignment |
| `contentRichIframe` | `model`,`id` | `model`,`id`,`slug` | `slug` nunca é gravado pelo editor |
| `contentRichLink` | `model`,`id`,`slug`,`title` | os 4 | link inline |
| `contentRichColumns` | — (`contentRichColumn{2}`) | — | exatamente 2 |
| `contentRichColumn` | — (`block+`) | — | |
| `contentRichFaq` | `items[{question,answer}]` | idem | `answer` cru |
| `contentRichCtaButton` | `text`,`url` | `text`,`url`,`bgColor`,`textColor` | cores não persistem |
| `contentRichStepByStep` | `steps[{title,description,icon}]` | idem | |
| `contentRichCards` | `cards[{icon,title,description,link}]` | idem | ícone `fa-`→`bi-` |
| `contentRichInteractiveTable` | 9 attrs (ver exemplo) | os 9 | TSV |
| `contentRichCommandLine` | `leftButtons`,`rightButtons`,`backgroundColor` | idem | botões viram `<a target=_blank>` |
| `contentRichCarousel` | `title`,`subtitle`,`backgroundImageUrl`,`items[{icon,title,text}]` | idem | `text` cru |
| `contentRichHtmlFree` | `html` | `html` | cru |
| `contentRichYoutube` | `url` | `url` | URL → embed |

## 7. Configuração e permissões

- **Nenhuma chave `config()`/`env()` é lida pelo unit** (`grep` em `V2/Services/ContentRichEditor`
  só encontra `asset()` na linha do bundle). `config.csv` é emitido só com cabeçalho.
- **Dependências de configuração indiretas:** o namespace de views vem do nome da pasta
  (`V2/Core/Providers/V2ServiceProvider.php:176,187`); o bundle é referenciado por caminho
  fixo `assets/js/content-rich-editor-bundle.js` (`V2/Services/ContentRichEditor/Views/components/forms/content-rich-editor.blade.php:425`); as cores do conteúdo
  usam variáveis CSS `--tenant-primary`/`--tenant-accent` com fallback
  (`V2/Services/ContentRichEditor/Parser/Nodes/ContentRichCtaButtonRenderer.php:22-23`).
- **Permissões:** não há slug de permissão do editor. O acesso é "autenticado + possui role",
  porque `/cms/v2/editor/*` não pertence a nenhum `active_pattern`
  (`V2/Core/Middleware/V2PermissionMiddleware.php:72-76`). `super-admin` e o e-mail de suporte
  passam direto (`:66-69`).
- **Flag de módulo:** não se aplica (serviço não tem `ModuleProvider`).
- **CSRF:** rotas no grupo `web` → `VerifyCsrfToken`; o JS envia `_token` no upload
  (`V2/Services/ContentRichEditor/Assets/js/modals.js:84-88`) e não envia no GET de busca.

## 8. Observabilidade e erros

| Situação | Comportamento |
|---|---|
| Falha de upload | `Log::error('[ContentRichEditor] Falha no upload: ' . $e->getMessage())`; `500` JSON com `"Falha interna ao processar arquivo."` (`V2/Services/ContentRichEditor/Controllers/ContentRichEditorController.php:52-53`). |
| Falha na busca | `Log::error('[ContentRichEditor] Falha na busca de registros: …')`; `200 []` (`:92-93`). |
| Falha no card de iframe | `Log::warning('[ContentRichEditor] Falha ao carregar iframe-summary', …)`; renderiza o card fallback (`V2/Services/ContentRichEditor/Views/components/iframe-summary.blade.php:26-28`). |
| Falha de render no viewer | `Log::error('[ContentRichEditor] Falha ao renderizar AST', …)`; nada é impresso (`V2/Services/ContentRichEditor/Views/components/content-rich-viewer.blade.php:11-15`). |
| Comando de toolbar desconhecido | `console.warn('[ContentRichEditor] Comando não mapeado:', cmd)` (`V2/Services/ContentRichEditor/Assets/js/logic/editor-commands.js:290-291`). |
| `contentRichSection` legado | `console.warn('[ContentRichEditor] Migração: contentRichSection detectado e desembrulhado.')` (`V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:31`). |
| Ausência de Bootstrap | tooltip não inicializa; retry único após 500 ms (`V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:101-115`). |
| Auditoria | Nenhum `ActivityLogger` neste serviço; quem audita é o módulo hospedeiro. |

## 9. Consumidores (mapa de integração)

| Consumidor | O que usa | Como usa | Risco de quebra |
|---|---|---|---|
| Notícias (CMS) | Editor `content` | include em `V2/Modules/Publicacoes/Noticias/Views/cms/includes/_editor-content.blade.php:349-354`; salva string sem validar JSON (`V2/Modules/Publicacoes/Noticias/Requests/NewsRequest.php:34`; `V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:256,423`) | Alto: corpo principal; JSON inválido só falha no render. |
| Notícias (preview) | `toHtml` | `previewIframe` (`V2/Modules/Publicacoes/Noticias/Controllers/NoticiasCmsController.php:565-571`) + iframe escuta `contentRichPreviewUpdate` (`V2/Modules/Publicacoes/Noticias/Views/cms/preview-iframe.blade.php:269-277`) | Alto: preview depende do renderer e do postMessage. |
| Notícias (portal) | `toHtml` | `V2/Modules/Publicacoes/Noticias/Controllers/NoticiasPortalController.php:101-103,161-163`; CSS via `viewer-styles` (`V2/Modules/Publicacoes/Noticias/Views/portal/show.blade.php:282-283`) | Alto. |
| SobreNos | Editor `bio` + `toHtml` | include em `V2/Modules/Institucional/SobreNos/Views/cms/members/_form.blade.php:52-56`; `V2/Modules/Institucional/SobreNos/Models/BoardMember.php:172-186` (`getHtmlBio`); portal `V2/Modules/Institucional/SobreNos/Controllers/SobreNosPortalController.php:129` | Médio: biografia de conselheiros (jsonb array). |
| SobreNos (página) | **não usa este editor** para o corpo | usa `LandingSectionsEditor` (`V2/Modules/Institucional/SobreNos/Controllers/SobreNosPortalController.php:40-67`) | Baixo. |
| História | Editor `description` + `toHtml` | `V2/Modules/Institucional/Historia/Views/cms/edit.blade.php:80-83`; `V2/Modules/Institucional/Historia/Controllers/MilestoneCmsController.php:265-277`; `V2/Modules/Institucional/Historia/Models/Milestone.php:67-76` (`getHtmlDescription`) | Médio. |
| Parceiros Institucionais | Editor `description` + `toHtml` | `V2/Modules/Conexoes/ParceirosInstitucionais/Views/cms/edit.blade.php:158`; `V2/Modules/Conexoes/ParceirosInstitucionais/Models/Parceiro.php:128-137` (`getHtmlDescription`); `V2/Modules/Conexoes/ParceirosInstitucionais/Controllers/ParceirosInstitucionaisPortalController.php:40-41` | Médio. |
| Unidade Regional | Editor `description` + `toHtml` | `V2/Modules/Conexoes/UnidadeRegional/Views/cms/edit.blade.php:107`; `V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalPortalController.php:57-68` (faz `json_decode` + fallback escapado) | Médio: fallback divergente. |
| Associados/Instituições | Editor `description` + `toHtml` | `V2/Modules/Conexoes/Associados/Views/cms/instituicoes/edit.blade.php:116`; `V2/Modules/Conexoes/Associados/Controllers/AssociadosPortalController.php:140-141,184-185`; `V2/Modules/Conexoes/Associados/Controllers/InstituicaoCmsController.php:304-305` | Médio. |
| AssocieSe (planos) | Editor `description` + `toHtml` | `V2/Modules/Institucional/AssocieSe/Views/cms/plans/_form.blade.php:16`; `V2/Modules/Institucional/AssocieSe/Controllers/AssocieSeController.php:68`; `V2/Modules/Institucional/AssocieSe/Models/MembershipPlan.php:118-119` | Médio. |
| Trabalhe Conosco | Editor `description_detailed` + `toHtml` | `V2/Modules/Publicacoes/TrabalheConosco/Views/cms/openings/_form.blade.php:38`; `V2/Modules/Publicacoes/TrabalheConosco/Controllers/TrabalheConoscoController.php:61` | Médio. |
| Agenda | `toHtml` em `description` | `V2/Modules/Publicacoes/Agenda/Controllers/EventosPortalController.php:133` | Médio. |
| LandingSectionsEditor | Editor (N campos) + `toHtml` | `assets_only` em `V2/Services/LandingSectionsEditor/Views/components/forms/landing-sections-editor.blade.php:14`; formulários de seção e 11 renders chamam `toHtml` (`V2/Services/LandingSectionsEditor/Views/render/*.blade.php`) | Alto: qualquer regressão afeta todas as landing pages. |
| BasicPage | **não consome** | usa CodeMirror (`content_type` htmlmixed/css/javascript) em `V2/Modules/Layouts/BasicPage/Controllers/BasicPageCmsController.php:124-146` e `V2/Modules/Layouts/BasicPage/Views/cms/editor.blade.php:76-80` | Nenhum; migrar para AST exigiria novo campo. |
| StructuralLandingEditor | **não consome** este serviço; usa Gallery e blocos estruturais próprios | — | Nenhum direto. |

## 10. Lacunas

| ID | Lacuna | Impacto | Onde procurar |
|---|---|---|---|
| LG-CONTENTRICHEDITOR-01 | Não há política de sanitização para `contentRichHtmlFree`, `answer` de FAQ, `text` de slide e `tableTitle`. | Stored XSS restrito a quem pode editar conteúdo; decisão de produto ausente. | `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichHtmlFreeRenderer.php:20`; `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichFaqRenderer.php:32`; `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichCarouselRenderer.php:52`; `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichInteractiveTableRenderer.php:102` |
| LG-CONTENTRICHEDITOR-02 | Nenhuma permissão específica (slug/middleware) no upload/busca. | Qualquer usuário CMS autenticado pode enviar arquivos de até 10 MB e varrer o índice público. | `V2/Services/ContentRichEditor/Routes/cms.php:9-14`; `V2/Core/Middleware/V2PermissionMiddleware.php:72-76` |
| LG-CONTENTRICHEDITOR-03 | Modal de mídia próprio e endpoint `/upload` órfãos no fluxo padrão. | Código e rota mantidos sem uso aparente. | `V2/Services/ContentRichEditor/Views/components/modals/media-modal.blade.php:1`; `V2/Services/ContentRichEditor/Assets/js/modals.js:4,76-136` |
| LG-CONTENTRICHEDITOR-04 | `lotepEditorPrompt`/`lotepEditorComplexPrompt` dependem do plugin jQuery do Bootstrap 3, ausente no layout BS 5.3.2. | Inserção de YouTube, link, FAQ, CTA, passos, cards, tabela interativa, barra de ações, carrossel e HTML livre falha no browser. `[C]` | `V2/Services/ContentRichEditor/Assets/js/logic/editor-modals.js:33-36,142`; `V2/Services/ContentRichEditor/Assets/js/modals.js:248`; `V2/layouts/cms/layouts/app.blade.php:101` |
| LG-CONTENTRICHEDITOR-05 | `bgColor`/`textColor` do CTA e `slug` do iframe não fazem parte dos schemas. | Cores e slug são descartados silenciosamente. | `V2/Services/ContentRichEditor/Assets/js/nodes/content-rich-cta-button.js:6-11`; `V2/Services/ContentRichEditor/Assets/js/nodes/content-rich-iframe-summary.js:7-12` |
| LG-CONTENTRICHEDITOR-06 | `codeBlock`, `hardBreak` e mark `code` (StarterKit) não têm renderer. | Blocos de código aparecem como texto concatenado sem quebras. | `V2/Services/ContentRichEditor/Parser/ContentASTParser.php:119-256` (nenhum registro) |
| LG-CONTENTRICHEDITOR-07 | `toMarkdown` incompleto e sem consumidor. | Exportação Markdown não é paridade confiável. | `V2/Services/ContentRichEditor/Renderers/MarkdownStrategy.php:25-77` |
| LG-CONTENTRICHEDITOR-08 | `viewer-scripts` vazio; accordion/carrossel dependem de Bootstrap do portal. | FAQ/carrossel não funcionam se o tema público não carregar Bootstrap JS. | `V2/Services/ContentRichEditor/Views/components/partials/viewer-scripts.blade.php:1-2` |
| LG-CONTENTRICHEDITOR-09 | Conteúdo legado (HTML puro) tem tratamento divergente por módulo. | Páginas antigas podem exibir HTML escapado ou vazio. | `V2/Modules/Conexoes/UnidadeRegional/Controllers/UnidadeRegionalPortalController.php:57-68` vs `V2/Modules/Conexoes/ParceirosInstitucionais/Models/Parceiro.php:128-137` |
| LG-CONTENTRICHEDITOR-10 | IDs voláteis em FAQ/carrossel/tabela. | Impossibilita cache de fragmento e links profundos. | `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichFaqRenderer.php:25`; `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichCarouselRenderer.php:28`; `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichInteractiveTableRenderer.php:29` |
| LG-CONTENTRICHEDITOR-11 | Nenhum teste automatizado no unit. | Paridade depende de inspeção manual. | `find V2/Services/ContentRichEditor -name '*Test*'` → vazio |

## 11. Checklist de paridade

- [ ] `PC-CONTENTRICHEDITOR-01` — Editar um parágrafo e submeter: o request contém `content` com `{"type":"doc",...}` e o texto digitado (`V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:42-49`).
- [ ] `PC-CONTENTRICHEDITOR-02` — `toHtml('{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Oi"}]}]}')` retorna exatamente `<p>Oi</p>`.
- [ ] `PC-CONTENTRICHEDITOR-03` — AST com `bold`, `italic`, `strike`, `underline`, `highlight`, `outdated` e `link` produz `strong/em/s/u/mark/del/a`; `link` sem `rel` recebe `noopener noreferrer`.
- [ ] `PC-CONTENTRICHEDITOR-04` — `toHtml` com raiz diferente de `doc`, string vazia ou `null` retorna `""` sem erro.
- [ ] `PC-CONTENTRICHEDITOR-05` — JSON com `contentRichSection` como único filho é desembrulhado no browser e o input passa a ter a raiz plana (`V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:26-33`).
- [ ] `PC-CONTENTRICHEDITOR-06` — Campo com HTML legado carrega no editor e, após a primeira edição, o input contém JSON.
- [ ] `PC-CONTENTRICHEDITOR-07` — `GET /cms/v2/editor/search?q=a` retorna `[]`; `q=no` retorna no máximo 10 itens com `model` igual ao `module` do índice.
- [ ] `PC-CONTENTRICHEDITOR-08` — `POST /cms/v2/editor/upload` com PNG < 10 MB retorna `success:true` e `path` gravável sob `/storage/`.
- [ ] `PC-CONTENTRICHEDITOR-09` — `contentRichMedia` com `path` relativo renderiza `src="/storage/..."`; com `rotation:90` inclui `rotate(90deg)`; PDF vira card com `data-pdf-src`.
- [ ] `PC-CONTENTRICHEDITOR-10` — `contentRichHtmlFree` devolve o HTML exatamente como salvo (sem escape).
- [ ] `PC-CONTENTRICHEDITOR-11` — `contentRichIframe` com `model`/`id` de registro existente imprime título e link do índice; sem registro imprime "Conteúdo relacionado" e `href="#"`.
- [ ] `PC-CONTENTRICHEDITOR-12` — `ContentRichLinkRenderer::urlFor('noticias','x','')` retorna `/noticia/x`; `('basicpage','sobre','')` retorna `/sobre`; `('parceiros-institucionais','', 'uuid')` retorna `/parceiros-institucionais/detalhe/uuid`.
- [ ] `PC-CONTENTRICHEDITOR-13` — `contentRichInteractiveTable` com TSV de 3 linhas gera `<thead>` com a primeira linha (quando `hasHeader`), filtra por termo com debounce de 200 ms e mostra "Nenhum registro encontrado para sua busca." quando vazio.
- [ ] `PC-CONTENTRICHEDITOR-14` — `contentRichFaq` gera `aria-expanded="false"`, `role="tab"`, `data-bs-parent` e ids correlacionados; duas instâncias na mesma página não colidem.
- [ ] `PC-CONTENTRICHEDITOR-15` — Página com 5 editores (`parameters[items][n][text]`) inicializa todos, envia cada um no submit e injeta os assets uma única vez.
- [ ] `PC-CONTENTRICHEDITOR-16` — Digitar no editor de Notícias atualiza o iframe de preview após ~800 ms com `type: contentRichPreviewUpdate`.
- [ ] `PC-CONTENTRICHEDITOR-17` — Comando `contentRichMedia` abre a Galeria global e o callback insere o nó com `path` relativo, `alt` e `type` correto.
- [ ] `PC-CONTENTRICHEDITOR-18` — `content-rich-viewer` com AST vazio não imprime `<style>` nem wrapper.
- [ ] `PC-CONTENTRICHEDITOR-19` — O bundle gerado por `Assets/build-bundle.sh` é byte-equivalente ao publicado em `public/assets/js/content-rich-editor-bundle.js`.
- [ ] `PC-CONTENTRICHEDITOR-20` — `contentRichCards` com ícone `fa-star` gera classe `bi-star`; links abrem em `_blank` com `rel="noopener noreferrer"`.

## 12. Evidências consultadas

- `V2/Services/ContentRichEditor/README.md:1-120`; `V2/Services/ContentRichEditor/Assets/README.md:1-41`; `V2/Services/ContentRichEditor/Parser/README.md:1-45`; `V2/Services/ContentRichEditor/Controllers/README.md:1-24`; `V2/Services/ContentRichEditor/Routes/README.md:1-19`; `V2/Services/ContentRichEditor/Views/README.md:1-26`; `V2/Services/ContentRichEditor/Renderers/README.md:1-12`; `V2/Services/ContentRichEditor/Services/README.md:1-30`; `V2/Services/ContentRichEditor/LIVE_PREVIEW_IMPLEMENTATION.md:1-115`.
- `V2/Services/ContentRichEditor/Parser/ContentASTParser.php:1-258`; `V2/Services/ContentRichEditor/Parser/NodeRendererInterface.php:1-8`; `V2/Services/ContentRichEditor/Parser/MarkRendererInterface.php:1-8`; `V2/Services/ContentRichEditor/Parser/Nodes/*.php` (26 arquivos); `V2/Services/ContentRichEditor/Parser/Marks/*.php` (7 arquivos).
- `V2/Services/ContentRichEditor/Renderers/HtmlStrategy.php:1-33`; `V2/Services/ContentRichEditor/Renderers/MarkdownStrategy.php:1-84`; `V2/Services/ContentRichEditor/Renderers/Contracts/RendererStrategyInterface.php:1-17`.
- `V2/Services/ContentRichEditor/Services/ContentRichEditorRendererService.php:1-36`.
- `V2/Services/ContentRichEditor/Controllers/ContentRichEditorController.php:1-96`; `V2/Services/ContentRichEditor/Routes/cms.php:1-14`.
- `V2/Services/ContentRichEditor/Assets/js/content-rich-editor.js:1-134`; `V2/Services/ContentRichEditor/Assets/js/_bundle-header.js:1-89`; `V2/Services/ContentRichEditor/Assets/js/modals.js:1-262`; `V2/Services/ContentRichEditor/Assets/js/logic/editor-commands.js:1-293`; `V2/Services/ContentRichEditor/Assets/js/logic/editor-extensions.js:1-95`; `V2/Services/ContentRichEditor/Assets/js/logic/editor-modals.js:1-171`; `V2/Services/ContentRichEditor/Assets/js/logic/editor-toolbar-state.js:1-60`; `V2/Services/ContentRichEditor/Assets/js/nodes/*.js` (14); `V2/Services/ContentRichEditor/Assets/js/marks/content-rich-outdated.js:1-19`; `V2/Services/ContentRichEditor/Assets/build-bundle.sh:1-72`.
- `V2/Services/ContentRichEditor/Views/components/forms/content-rich-editor.blade.php:1-427`; `V2/Services/ContentRichEditor/Views/components/content-rich-viewer.blade.php:1-25`; `V2/Services/ContentRichEditor/Views/components/iframe-summary.blade.php:1-42`; `V2/Services/ContentRichEditor/Views/components/layouts/split-screen.blade.php:1-123`; `V2/Services/ContentRichEditor/Views/components/modals/media-modal.blade.php:1-64`; `V2/Services/ContentRichEditor/Views/components/modals/search-modal.blade.php:1-53`; `V2/Services/ContentRichEditor/Views/components/partials/viewer-styles.blade.php:1-697`.
- Consumidores: `V2/Modules/Publicacoes/Noticias/{Controllers,Views,Requests,Migrations}`; `V2/Modules/Institucional/SobreNos/{Controllers,Models,Views,Migrations}`; `V2/Modules/Institucional/Historia/*`; `V2/Modules/Conexoes/ParceirosInstitucionais/*`; `V2/Modules/Conexoes/UnidadeRegional/*`; `V2/Modules/Conexoes/Associados/*`; `V2/Modules/Institucional/AssocieSe/*`; `V2/Modules/Publicacoes/TrabalheConosco/*`; `V2/Modules/Publicacoes/Agenda/*`; `V2/Modules/Layouts/BasicPage/*`; `V2/Services/LandingSectionsEditor/*`.
- Infra: `V2/Core/Providers/V2ServiceProvider.php:169-215`; `V2/Core/Middleware/CmsAuthenticate.php:1-46`; `V2/Core/Middleware/V2PermissionMiddleware.php:1-120`; `V2/Core/Middleware/CspMiddleware.php:1-110`; `app/Http/Kernel.php:20`; `V2/layouts/cms/layouts/app.blade.php:100-139`; `public/assets/vendor/tiptap/README.txt`.

## 13. Apêndice — checklist de extração (A–M)

Respostas por item; "ver §" remete às seções deste documento. Os companion docs
[ast-e-renderers.md](ast-e-renderers.md) e [ui-contrato.md](ui-contrato.md) complementam B, C, D,
G, H e I.

### A. Efeito

- **A1.** Sim: o autor edita e o serviço persiste JSON AST; o portal converte o JSON em HTML (§0, §1.1).
- **A2.** Entradas: include Blade (5 props), JSON no `value`, `POST /upload` (arquivo+alt+CSRF), `GET /search?q`, `postMessage` de preview (§1.2, §5).
- **A3.** Saídas: input hidden JSON, HTML, Markdown, card de iframe, JSON de upload/busca (§1.3).
- **A4.** Efeitos colaterais: arquivo em `editor_uploads/`, variantes de mídia, logs de erro/aviso, listeners globais; sem banco, cache, e-mail ou auditoria (§1.2, §8).
- **A5.** Contrato observável com resultados e códigos 200/401/403/422/500 (§1.3).
- **A6.** Não valida AST, não autoriza por módulo, não sanitiza HTML livre, não migra legado, não faz cache, não destrói instâncias (§1.4).
- **A7.** Por perfil: público (HTML), CMS autor (editor), CMS read-only (abre mas não salva), API (não exposta), operador regional (escopo no módulo) (§1.5).

### B. Abordagem

- **B1.** Fluxo AST→strategy→parser→registry→HTML, com diagrama (§2.2).
- **B2.** Mecânica em 12 passos com classes/métodos reais (§2.2).
- **B3.** Decisões: AST em vez de HTML, parser próprio por compatibilidade, registry + strategy (§2.1).
- **B4.** 6 trechos pivô citados e comentados (§2.3).
- **B5.** Custos: O(nós), sem cache, 1 query por iframe, ILIKE sem índice, upload em memória (§2.4).
- **B6.** Estado: input hidden como fonte da verdade, `data-initialized`, `activeEditorInstance`, `__lotepEditorLastHtml`; sem invalidação de cache (§4.3).
- **B7.** Falhas: upload com `try/catch`+500; busca fail-open `[]`; card de iframe com fallback; `TypeError` de AST malformada **não** é tratado (§8; ast §3.3).

### C. Particularidades

- **C1.** Fonte única do AST Tiptap e da tabela `model→URL` (§3.1).
- **C2.** Comparação com LSE (reutiliza o editor) e Media (dono do arquivo) (§3.1).
- **C3.** 14 invariantes `RN-CONTENTRICHEDITOR-01..14` (§3.2).
- **C4.** Casos-limite: vazio, não-JSON, raiz errada, filho não-array, PDF sem path, busca curta, registro removido, duas instâncias, nome com colchetes (§3.3).
- **C5.** Efeitos de segunda ordem: IDs voláteis, atributos descartados, HTML cru, query por card (§3.3, §3.5).
- **C6.** Armadilhas de ordem do bundle, CSS separado, schema como validação, sanitização seletiva (§3.4).
- **C7.** Dívidas/divergências README×código em 14 itens (§3.5).

### D. Integrabilidade

- **D1.** Superfície pública com assinaturas/retornos/precondições (§4.1).
- **D2.** Registro: provider carrega views/rotas; layout precisa de jQuery, BS5, picker e stacks (§4.4).
- **D3.** Ordem: provider → layout → include → bundle → `initContentRichEditors` → `Editor` (§4.4).
- **D4.** Contrato de UI completo no companion [ui-contrato.md](ui-contrato.md) §3.1–3.4.
- **D5.** 5 receitas com resultado esperado (§4.5).
- **D6.** 5 anti-usos com efeito (§4.6).
- **D7.** Limites: 10 MB/21 mimes, busca 2 chars/10 itens, debounce 800 ms, sem throttle (§4.7).
- **D8.** 15 consumidores com risco (§9).
- **D9.** Extensão: `addNodeRenderer`/`addMarkRenderer`, nova strategy, renderer próprio (ast §3.5, §4.2).

### E. Rotas e HTTP

- **E1.** 1 grupo + 2 endpoints com middleware/controller/permissão (§5).
- **E2.** Parâmetros `file`, `alt`, `_token`, `q`; sem paginação/ordenação; `q<2` → `[]` (§5).
- **E3.** Validação server-side só no upload (`required|file|max:10240|mimes:...`); sem FormRequest (§5).
- **E4.** Sucesso e erro de cada endpoint, com mensagens exatas (§1.3, §5).
- **E5.** Auth por sessão Sentinel + CSRF `web`; sem token/API (§5, §7).
- **E6.** Sem throttle, cache ou idempotência: repetir upload cria novo arquivo (§4.7).
- **E7.** Rotas fixas; sem rotas dinâmicas.

### F. Dados

- **F1–F3.** O serviço não tem tabela/model; as colunas dos consumidores, tipos e casts estão em §6.
- **F4.** Sem observers/eventos; o índice de busca é populado pelos modelos consumidores.
- **F5.** Formato AST campo a campo e atributos por nó (§6.1; ast §3.2).
- **F6.** Volume/cardinalidade: 1 documento por registro; 1 query por card; sem paginação no render (§2.4).

### G. UI CMS

- **G1.** Editor em formulários de 10 módulos; layout split-screen opcional (§9).
- **G2.** Campos: `name`, `label`, `value` (JSON), `required` (asterisco visual), `assets_only`; validação server-side é do módulo (§4.2).
- **G3.** Não se aplica: o serviço não tem listagem.
- **G4.** Modais de busca/mídia/PDF/prompts, com textos exatos (ui §3.5).
- **G5.** JS: init no ready e reinit por container; AJAX para `/search` e `/upload`; CSRF em `_token`; debounce de 800 ms no preview (ui §3.3–3.4).
- **G6.** A11y: pontos fortes e gaps (ui §3.8).

### H. UI Portal

- **H1.** Exibe HTML do AST; visibilidade é do módulo (status/regional) (§9).
- **H2.** H2–H4 no conteúdo; H1 é do layout (`heading levels [2,3,4]`, §3.2 RN-05).
- **H3.** Não emite metatags; o README recomenda `strip_tags` + `str_limit` para description (§4.6).
- **H4.** Mídia: `/storage/<path>`, `loading=lazy`, `alt` do autor, filename → alt vazio; PDF como card (§3.3; ast §3.2).
- **H5.** Responsivo por classes Bootstrap; sem CSS de impressão (ui §3.6).
- **H6.** Sem cache próprio; cache é do módulo/middleware (§1.2).

### I. Contrato de UI/JS

- **I1–I2.** Componentes, props, slots, IDs e `data-*` (ui §4.1, §3.1).
- **I3.** Eventos e payloads (ui §3.3).
- **I4.** Init/teardown, multi-instância e reinit (ui §3.2, §3.4).
- **I5.** Estado persistido: input hidden JSON; globais de instância/preview (ui §3.2–3.3).
- **I6.** Dependências e ordem (Tiptap 2.0.0, jQuery, BS5); comportamento sem JS (ui §2.5, §3.6).
- **I7.** Acessibilidade (ui §3.8, §5).
- **I8.** Strings pt-BR: títulos de modais, placeholders, mensagens de erro; sem i18n (constantes no Blade/JS).

### J. Configuração, permissões e flags

- **J1.** Nenhuma chave de config/env (§7).
- **J2.** Sem permissão própria; `CmsAuthenticate` + `V2PermissionMiddleware` liberam rota fora de `active_pattern` (§7; `V2/Core/Middleware/V2PermissionMiddleware.php:72-76`).
- **J3.** Não se aplica (serviço não tem `ModuleProvider`).
- **J4.** Não se aplica (sem flag de módulo).
- **J5.** Não se aplica (sem feature toggle).

### K. Observabilidade e limites

- **K1.** Logs: upload (error), busca (error), iframe-summary (warning), viewer (error) (§8).
- **K2.** Sem `ActivityLogger`; auditoria fica nos módulos (§8).
- **K3.** 200/401/403/422/500, fail-open na busca (§1.3, §8).
- **K4.** Limites: 10 MB, 21 mimes, 10 resultados, 800 ms, sem throttle (§4.7).
- **K5.** Dados sensíveis: nenhum segredo; o índice público é varrido por qualquer usuário CMS (§7, LG-02).

### L. Seeds e dados iniciais

- **L1–L3.** Não se aplica: o serviço não possui seeder, migration ou dado semeado (§6).

### M. Paridade e lacunas

- **M1.** 20 itens `PC-CONTENTRICHEDITOR-01..20` (§11) + 11 no companion de AST (`PC-...-21..31` em [ast-e-renderers.md](ast-e-renderers.md) §11) + 17 no de UI (`PC-...-32..48` em [ui-contrato.md](ui-contrato.md) §7).
- **M2.** 25 lacunas `LG-CONTENTRICHEDITOR-01..25` (§10; ast §10; ui §6).
- **M3.** Evidências por arquivo/faixa (§12; companion §12/§8).
- **M4.** Verificação: `lint_docs.py` limpo nos 3 docs; `coverage.py` 85/85 arquivos da unidade; `merge_indices.py --check` OK; `git -C sucesu-cms status --porcelain V2` vazio.
