---
id: SVC-CONTENTRICHEDITOR
tipo: servico
onda: 2
status: concluido
unidades: [SVC-CONTENTRICHEDITOR]
fontes:
  - V2/Services/ContentRichEditor/
depende_de: [CORE-BOOT]
consumidores:
  - MOD-PUBLICACOES-NOTICIAS
  - MOD-INSTITUCIONAL-SOBRENOS
  - MOD-CONEXOES-PARCEIROSINSTITUCIONAIS
  - SVC-LANDINGSECTIONSEDITOR
criticidade: alta
dono: SvcCre
---

# AST e Renderers do ContentRichEditor

> Documento de apoio do serviço `SVC-CONTENTRICHEDITOR`. O ciclo completo está em
> [spec.md](spec.md); Blade/DOM/eventos em [ui-contrato.md](ui-contrato.md).

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | Traduzir a árvore JSON do Tiptap em HTML (ou Markdown) por um registry de renderers por `type`, sem dependências externas de Tiptap no PHP. |
| Entrada | `array` AST com raiz `doc` (ou string JSON, normalizada antes). |
| Saída | String HTML (ou Markdown), com escape seletivo de texto/atributos e HTML cru em quatro nós. |
| Efeitos colaterais | 1 query em `v2_public_search_index` por `contentRichIframe`; `Log::warning`/`Log::error` em falhas; nenhum cache. |
| Particularidade central | O contrato real é a **interseção entre o schema JS e as chaves lidas pelo renderer PHP**; não há validação. Nós desconhecidos são "transparentes"; atributos não declarados no JS são descartados antes de persistir. |
| Como se integra | `ContentRichEditorRendererService::toHtml()/toMarkdown()`; `ContentASTParser` público para extensão (`addNodeRenderer`, `addMarkRenderer`). |
| Se removido, quebra | Todo o conteúdo rico já gravado em 10 módulos + 11 renders do LandingSectionsEditor. |
| Superfície pública | `ContentASTParser`, `MarkRendererInterface`, `NodeRendererInterface`, `RendererStrategyInterface`, `HtmlStrategy`, `MarkdownStrategy`, 26 classes `Nodes\*`, 7 classes `Marks\*`. |

## 1. Efeito

**Entrada → saída.** Um objeto `{"type":"paragraph","attrs":{"textAlign":"center"},"content":[{"type":"text","text":"Oi","marks":[{"type":"bold"}]}]}`
renderiza `<p style="text-align: center"><strong>Oi</strong></p>`.

**O que produz por categoria:**

| Categoria | Efeito |
|---|---|
| Texto/blocos nativos | HTML semântico (`p`, `h2..h4`, `ul`, `ol`, `li`, `blockquote`, `hr`, `table`), com `style` inline para `textAlign`/`indent`. |
| Nós ricos | HTML com classes `content-rich-*`, `data-lotep-*` e Bootstrap 5; alguns com `<script>` inline (tabela interativa). |
| Nó de referência (`contentRichIframe`) | Renderiza uma view Blade que consulta o índice público em tempo de render. |
| Markdown | Subconjunto: parágrafos, headings, listas com marcador, `bold`/`italic`/`strike`/`outdated`, mídia, iframe e cross-link. O resto sai como HTML. |

**O que não faz:** não valida schema, não sanitiza HTML livre, não resolve URLs de mídia no
banco, não busca registros para `contentRichLink` (usa o que está em `attrs`), não faz
destaque de código, não emite `<thead>` para as tabelas nativas (ver §3.3).

## 2. Abordagem

### 2.1 Estratégia e fluxo

`HtmlStrategy` apenas delega (`V2/Services/ContentRichEditor/Renderers/HtmlStrategy.php:29-32`); `MarkdownStrategy`
constrói um `ContentASTParser` novo, chama `setupMarkdownRenderers()` e sobrescreve 11 tipos
(`V2/Services/ContentRichEditor/Renderers/MarkdownStrategy.php:19-78`). O traversal é **um só** para as duas saídas.

```mermaid
flowchart TD
  A[AST array] --> B{raiz type == doc?}
  B -- não --> Z["''"]
  B -- sim --> C[renderNode doc]
  C --> D[para cada filho: renderNode]
  D --> E{tem content array?}
  E -- sim --> D
  E -- não e type text --> F["htmlspecialchars(text)"]
  F --> G[para cada mark: renderMark]
  D --> G
  G --> H{nodeRenderers[type]?}
  H -- sim --> I["call_user_func(closure, node, content)"]
  H -- não --> J[devolve content]
  I --> K[HTML/Markdown]
  J --> K
```

### 2.2 Ordem de resolução (contrato)

1. **Raiz.** `parse()` exige `$ast['type'] === 'doc'`; caso contrário `''`
   (`V2/Services/ContentRichEditor/Parser/ContentASTParser.php:34-41`).
2. **Descida.** Se o nó tem `content` array, cada filho é renderizado e concatenado. Se não
   tem e é `text`, o texto é escapado. Um nó com `content` e `type: "text"` nunca usa `text`.
3. **Marks.** Para cada item de `marks`, na ordem do array, `renderMark($mark, $content)` é
   aplicado sobre a string acumulada — logo, a última mark do array é a **mais externa**.
4. **Nó.** Só então o renderer do `type` é chamado, recebendo o nó completo (attrs) e o
   conteúdo já renderizado.
5. **Fallback.** Tipo sem renderer devolve o conteúdo dos filhos; mark sem renderer devolve o
   conteúdo inalterado (`:68-72`, `:82-91`).

### 2.3 Trechos de código pivô

```php
// V2/Services/ContentRichEditor/Parser/ContentASTParser.php:119-133 — o registry é populado
// no construtor; cada tipo delega a uma classe especializada com a mesma assinatura.
protected function registerDefaultRenderers()
{
    // Document Root
    $this->addNodeRenderer('doc', function ($node, $content) {
        return (new Nodes\DocRenderer())->render($node, $content);
    });

    // Basic HTML Elements
    $this->addNodeRenderer('paragraph', function ($node, $content) {
        return (new Nodes\ParagraphRenderer())->render($node, $content);
    });

    $this->addNodeRenderer('heading', function ($node, $content) {
        return (new Nodes\HeadingRenderer())->render($node, $content);
    });
```

Os renderers são instanciados **por nó** (uma alocação por ocorrência), não reutilizados.

```php
// V2/Services/ContentRichEditor/Parser/ContentASTParser.php:155-158 — nó custom segue o mesmo
// padrão dos nativos; não há distinção de tratamento entre "core" e "Lotep".
        // ContentRich Custom Nodes
        $this->addNodeRenderer('contentRichMedia', function ($node, $content) {
            return (new Nodes\ContentRichMediaRenderer())->render($node, $content);
        });
```

```php
// V2/Services/ContentRichEditor/Parser/Nodes/ContentRichLinkRenderer.php:48-78 — resolução de
// rota hardcoded; é a única tradução model→URL do portal.
public static function urlFor($model, $slug, $id)
{
    $model = strtolower(trim((string) $model));
    $key = trim((string) $slug) !== '' ? trim((string) $slug) : trim((string) $id);

    switch ($model) {
        case 'noticia':
        case 'noticias':
            return '/noticia/' . $key;
        // ... agenda, unidade-regional, associado, trabalhe-conosco, parceiros ...
        case 'basicpage':
        case 'basic_pages':
            return $slug !== '' ? '/' . $key : '';
        default:
            return $slug !== '' ? '/' . $model . '/' . $slug : '/' . $model . '/' . $id;
    }
}
```

O `default` monta `/{model}/{slug}` — um `model` arbitrário vindo do índice pode gerar URL
inexistente (404 silencioso, sem validação).

```php
// V2/Services/ContentRichEditor/Parser/Nodes/ContentRichCardsRenderer.php:26-35 — normalização
// de ícone: aceita Font Awesome legado e converte para Bootstrap Icons.
foreach ($cards as $card) {
    $iconHtml = '';
    if (!empty($card['icon'])) {
        $icon = htmlspecialchars($card['icon'], ENT_QUOTES, 'UTF-8');
        $icon = str_replace('fa-', 'bi-', $icon);
        if (strpos($icon, 'bi-') !== 0) {
            $icon = 'bi-' . $icon;
        }
        $iconHtml = '<div class="content-rich-card-icon"><i class="' . $icon . '"></i></div>';
    }
```

```php
// V2/Services/ContentRichEditor/Parser/Nodes/ContentRichMediaRenderer.php:50-59 — regra do alt:
// nome de arquivo não vira texto alternativo nem legenda visível.
$alt = $attrs['alt'] ?? '';
$width = isset($attrs['width']) ? (int) $attrs['width'] : 100;
$alignment = $attrs['alignment'] ?? 'center';
$rotation = isset($attrs['rotation']) ? (int) $attrs['rotation'] : 0;

// Detecta se o alt é um nome de arquivo (ex: "imagem.png", "foto.jpg")
$isFilename = !empty($alt) && (bool) preg_match('/\.(png|jpe?g|gif|svg|webp|bmp|ico|tiff?|pdf)$/i', $alt);
$imgAlt = $isFilename ? '' : $alt;
```

```php
// V2/Services/ContentRichEditor/Parser/Nodes/ContentRichInteractiveTableRenderer.php:100-108 —
// dois atributos entram em HTML sem escape: tableTitle (conteúdo) e tableWidth (style).
$tableTitleHtml = $tableTitle ? '<div class="content-rich-table-title">' . $tableTitle . '</div>' : '';

return '
    <section class="content-rich-table-section" id="section-' . $sectionId . '" style="max-width: ' . $tableWidth . ';">
        ' . $headerSection . '
        <div class="content-rich-table-container">
```

`$tableTitle` foi escapado na linha 21 (`htmlspecialchars`) — o valor já chega escapado a este
ponto. Já `$tableWidth` é cru (`:25`), permitindo `100%; background:url(...)`.

```php
// V2/Services/ContentRichEditor/Parser/Marks/LinkRenderer.php:25-46 — atributos montados à mão;
// rel default é sempre aplicado quando ausente.
$href = htmlspecialchars($href, ENT_QUOTES, 'UTF-8');
$htmlAttrs = ['href' => $href];

if (!empty($attrs['target'])) {
    $htmlAttrs['target'] = htmlspecialchars($attrs['target'], ENT_QUOTES, 'UTF-8');
}
if (!empty($attrs['rel'])) {
    $htmlAttrs['rel'] = htmlspecialchars($attrs['rel'], ENT_QUOTES, 'UTF-8');
} else {
    $htmlAttrs['rel'] = 'noopener noreferrer';
}

$attrHtml = '';
foreach ($htmlAttrs as $name => $value) {
    $attrHtml .= ' ' . $name . '="' . $value . '"';
}

return '<a' . $attrHtml . '>' . $content . '</a>';
```

Markdown não sobrescreve `link`; o mark continua produzindo HTML dentro do Markdown.

```php
// V2/Services/ContentRichEditor/Renderers/MarkdownStrategy.php:45-60 — marks sobrescritas e a
// única diferença semântica de conteúdo (outdated ganha sufixo textual).
$this->parser->addMarkRenderer('bold', function($mark, $content) {
    return '**' . $content . '**';
});
$this->parser->addMarkRenderer('italic', function($mark, $content) {
    return '*' . $content . '*';
});
$this->parser->addMarkRenderer('strike', function($mark, $content) {
    return '~~' . $content . '~~';
});
$this->parser->addMarkRenderer('outdated', function($mark, $content) {
    return '~~' . $content . '~~ (Defasado)';
});
```

## 3. Particularidades

### 3.1 Contratos formais

| Interface | Assinatura | Implementada por |
|---|---|---|
| `V2\Services\ContentRichEditor\Parser\NodeRendererInterface` | `render(array $node, string $content): string` (`V2/Services/ContentRichEditor/Parser/NodeRendererInterface.php:7-8`) | 26 classes `Parser\Nodes\*` |
| `V2\Services\ContentRichEditor\Parser\MarkRendererInterface` | `render(array $mark, string $content): string` (`V2/Services/ContentRichEditor/Parser/MarkRendererInterface.php:7-8`) | 7 classes `Parser\Marks\*` |
| `V2\Services\ContentRichEditor\Renderers\Contracts\RendererStrategyInterface` | `render(array $ast): string` (`V2/Services/ContentRichEditor/Renderers/Contracts/RendererStrategyInterface.php:16`) | `HtmlStrategy`, `MarkdownStrategy` |

**Invariante de contrato:** as classes implementam as interfaces, mas o parser **não as
tipa** — registra `callable` (`:98-106`). Uma closure arbitrária que devolva string é aceita.
As interfaces servem como convenção, não como verificação.

### 3.2 Mapa completo: nó Tiptap ⇄ AST ⇄ renderer PHP ⇄ saída

`type` é o nome do nó Tiptap e é a chave do registry. "JS" indica se o nó é declarado pelo
editor (`Assets/js/nodes`/`marks`).

| # | `type` (Tiptap) | JS | Classe PHP | Saída HTML | Saída Markdown | `attrs` |
|---|---|---|---|---|---|---|
| 1 | `doc` | `CustomDocument` (extensions) | `DocRenderer` | conteúdo concatenado | idem | — |
| 2 | `paragraph` | StarterKit | `ParagraphRenderer` | `<p style="text-align:…;padding-left:…">` | `content + "\n\n"` | `textAlign`, `indent` |
| 3 | `heading` | StarterKit (`levels:[2,3,4]`) | `HeadingRenderer` | `<hN style="…">` | `#{level} content\n\n` | `level`, `textAlign`, `indent` |
| 4 | `bulletList` | StarterKit | `BulletListRenderer` | `<ul>` | `content + "\n"` | — |
| 5 | `orderedList` | StarterKit | `OrderedListRenderer` | `<ol>` | **não sobrescrito** → `<ol>` | — |
| 6 | `listItem` | StarterKit | `ListItemRenderer` | `<li>` | `* trim(content)\n` | — |
| 7 | `blockquote` | StarterKit | `BlockquoteRenderer` | `<blockquote>` | **não sobrescrito** → `<blockquote>` | — |
| 8 | `horizontalRule` | StarterKit | `HorizontalRuleRenderer` | `<hr />` | **não sobrescrito** → `<hr />` | — |
| 9 | `hardBreak` | StarterKit | — (sem renderer) | conteúdo vazio | — | — |
| 10 | `codeBlock` | StarterKit | — (sem renderer) | texto dos filhos concatenado, sem `<pre>` | — | — |
| 11 | `table` | `@tiptap/extension-table` | `TableRenderer` | `<div class="table-responsive …"><table class="table …"><tbody>…</tbody></table></div>` | **não sobrescrito** → HTML | — |
| 12 | `tableRow` | extensão table | `TableRowRenderer` | `<tr>` | idem | — |
| 13 | `tableCell` | extensão table | `TableCellRenderer` | `<td colspan? rowspan?>` | idem | `colspan`, `rowspan` |
| 14 | `tableHeader` | extensão table | `TableHeaderRenderer` | `<th scope="col" colspan? rowspan?>` | idem | `colspan`, `rowspan` |
| 15 | `contentRichMedia` | `ContentRichMediaNode` | `ContentRichMediaRenderer` | imagem: `<figure class="content-rich-media-inline …"><img loading="lazy">` + `<figcaption>`; PDF: `<div class="content-rich-pdf-card" data-pdf-src onclick=…>` | `![Mídia](path)` | `path`, `type`, `variants`, `alt`, `rotation`, `width`, `alignment` |
| 16 | `contentRichIframe` | `ContentRichIframeSummaryNode` | `ContentRichIframeRenderer` | view `components.iframe-summary` (card com badge, título, resumo 180 chars, link) | `> [Resumo Dinâmico: model ID id]` | `model`, `id` (PHP também lê `slug`) |
| 17 | `contentRichLink` | `ContentRichCrossLinkNode` | `ContentRichLinkRenderer` | `<a class="content-rich-cross-link" data-model data-id href=…>title</a>` | `[Link Dinâmico id](/model/id)` | `model`, `id`, `slug`, `title` |
| 18 | `contentRichColumns` | `ContentRichColumnsNode` | `ContentRichColumnsRenderer` | `<div class="content-rich-columns-container">` | **não sobrescrito** → HTML | — (`content: contentRichColumn{2}`) |
| 19 | `contentRichColumn` | `ContentRichColumnNode` | `ContentRichColumnRenderer` | `<div class="content-rich-column-item">` | idem | — |
| 20 | `contentRichFaq` | `ContentRichFaqNode` | `ContentRichFaqRenderer` | accordion Bootstrap 5 (`role=tablist`, `role=tab`, `data-bs-toggle=collapse`, ids aleatórios) | **não sobrescrito** → HTML | `items[{question,answer}]` |
| 21 | `contentRichCtaButton` | `ContentRichCtaButtonNode` | `ContentRichCtaButtonRenderer` | `<div class="content-rich-cta-container"><a class="btn content-rich-cta-btn" style=…>` | HTML | `text`, `url` (PHP lê `bgColor`, `textColor`) |
| 22 | `contentRichStepByStep` | `ContentRichStepByStepNode` | `ContentRichStepByStepRenderer` | `<div class="content-rich-steps-container" data-lotep-steps>` com `<article role="listitem">` e setas | HTML | `steps[{title,description,icon}]` |
| 23 | `contentRichCards` | `ContentRichCardsNode` | `ContentRichCardsRenderer` | `<div data-lotep-cards><div class="row">` com `col-md-4` e cards (link opcional) | HTML | `cards[{icon,title,description,link}]` |
| 24 | `contentRichInteractiveTable` | `ContentRichInteractiveTableNode` | `ContentRichInteractiveTableRenderer` | `<section class="content-rich-table-section">` + busca + `<table>` + `<script>` de filtro | HTML | 9 attrs (ver §3.4) |
| 25 | `contentRichCommandLine` | `ContentRichCommandLineNode` | `ContentRichCommandLineRenderer` | `<div class="content-rich-command-line-wrapper" data-lotep-command-line>` com botões `<a target=_blank>` | HTML | `leftButtons`, `rightButtons`, `backgroundColor` |
| 26 | `contentRichCarousel` | `ContentRichCarouselNode` | `ContentRichCarouselRenderer` | `<div class="content-rich-carousel-wrapper" data-lotep-carousel>` + carousel BS5 (`data-bs-ride`, `carousel-item`, indicadores `<button>`) | HTML | `title`, `subtitle`, `backgroundImageUrl`, `items[{icon,title,text}]` |
| 27 | `contentRichHtmlFree` | `ContentRichHtmlFreeNode` | `ContentRichHtmlFreeRenderer` | **`attrs.html` cru** | HTML | `html` |
| 28 | `contentRichYoutube` | `ContentRichYoutubeNode` | `ContentRichYoutubeRenderer` | `<div data-lotep-youtube><figure><iframe youtube/embed>` | HTML | `url` |

> A tabela lista 28 entradas: 26 `type`s registrados no PHP + `hardBreak`/`codeBlock` (schema
> do StarterKit sem renderer). O editor declara **14 nós ricos + 1 mark** próprios.

**Marks (7 registradas + 1 do StarterKit sem renderer):**

| # | `type` | JS | Classe PHP | HTML | Markdown | attrs |
|---|---|---|---|---|---|---|
| 1 | `bold` | StarterKit | `BoldRenderer` | `<strong>` | `**x**` | — |
| 2 | `italic` | StarterKit | `ItalicRenderer` | `<em>` | `*x*` | — |
| 3 | `strike` | StarterKit | `StrikeRenderer` | `<s>` | `~~x~~` | — |
| 4 | `underline` | `@tiptap/extension-underline` | `UnderlineRenderer` | `<u>` | não sobrescrito → `<u>` | — |
| 5 | `highlight` | `@tiptap/extension-highlight` | `HighlightRenderer` | `<mark>` | não sobrescrito → `<mark>` | — |
| 6 | `outdated` | `ContentRichOutdatedMark` | `OutdatedRenderer` | `<del class="text-muted content-rich-outdated" title="Conteúdo defasado">` | `~~x~~ (Defasado)` | — |
| 7 | `link` | `@tiptap/extension-link` (`openOnClick:false`, `target=_blank`, `rel=noopener noreferrer`) | `LinkRenderer` | `<a href target rel>` | não sobrescrito → HTML | `href`, `target?`, `rel?` |
| — | `code` | StarterKit | — (sem renderer) | conteúdo sem `<code>` | — | — |

### 3.3 Casos de conteúdo inválido

| Entrada | Resultado real | Fonte |
|---|---|---|
| `[]`, `''`, `null`, `'texto'`, `'<p>x</p>'` | `""` | `V2/Services/ContentRichEditor/Services/ContentRichEditorRendererService.php:28-35`; `V2/Services/ContentRichEditor/Parser/ContentASTParser.php:35-41` |
| `{"type":"paragraph"}` sem `doc` na raiz | `""` | idem |
| `{"type":"doc"}` sem `content` | `""` (DocRenderer devolve `content` vazio) | `V2/Services/ContentRichEditor/Parser/ContentASTParser.php:49-72` |
| `{"type":"doc","content":["x"]}` (filho string) | **`TypeError` fatal**: `renderNode(array $node)` recebe string; `catch (\Exception)` não captura `\Error` | `V2/Services/ContentRichEditor/Parser/ContentASTParser.php:49`; `V2/Services/ContentRichEditor/Views/components/content-rich-viewer.blade.php:11` |
| Nó com `type` inexistente | filhos preservados, tag descartada | `V2/Services/ContentRichEditor/Parser/ContentASTParser.php:68-72` |
| `marks` com item sem `type` | mark ignorada | `V2/Services/ContentRichEditor/Parser/ContentASTParser.php:84-90` |
| `contentRichMedia` sem `path` | `""` | `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichMediaRenderer.php:23-25` |
| `contentRichFaq` com `items: []`/não-array | `""` | `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichFaqRenderer.php:22-24` |
| `contentRichCarousel` com `items: []` | `""` | `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichCarouselRenderer.php:24-26` |
| `contentRichCards`/`contentRichStepByStep` sem itens | `""` | `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichCardsRenderer.php:21-23`; `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichStepByStepRenderer.php:21-23` |
| `contentRichIframe` sem `model` ou `id` | `""` | `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichIframeRenderer.php:22-24` |
| `contentRichYoutube` com URL não-YouTube | `""` (buildEmbedUrl devolve `''`) | `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichYoutubeRenderer.php:16-28,45-62` |
| `contentRichColumns` com 1 coluna no JSON (fora do editor) | renderiza a coluna existente, sem validação | `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichColumnsRenderer.php:16-19` |
| Tabela nativa | `<th>` dentro de `<tbody>`: `TableRenderer` abre `<tbody>` e nunca `<thead>` | `V2/Services/ContentRichEditor/Parser/Nodes/TableRenderer.php:16-19`; `V2/Services/ContentRichEditor/Parser/Nodes/TableHeaderRenderer.php:16-30` |
| Markdown de `orderedList` | `<ol>` dentro do Markdown | `V2/Services/ContentRichEditor/Renderers/MarkdownStrategy.php:25-78` |

### 3.4 Atributos suportados por nó rico (schema JS × leitura PHP)

| Nó | JS `addAttributes` | PHP lê | Divergência |
|---|---|---|---|
| `contentRichMedia` | `path`, `type` (`image`), `variants` (`{}`), `alt`, `rotation` (`0`), `width` (`100`), `alignment` (`center`) | todos | `variants.original` tem prioridade sobre `path` no `src` (`V2/Services/ContentRichEditor/Parser/Nodes/ContentRichMediaRenderer.php:27-29`) |
| `contentRichIframe` | `model`, `id` | `model`, `id`, `slug` | `slug` não persiste pelo editor |
| `contentRichLink` | `model`, `id`, `slug`, `title` | todos | `title` default "Ver conteúdo" no PHP |
| `contentRichFaq` | `items[{question,answer}]` | idem | `answer` é HTML cru |
| `contentRichCtaButton` | `text` (`Clique Aqui`), `url` (`#`) | `text`, `url`, `bgColor`, `textColor` | cores nunca persistem |
| `contentRichStepByStep` | `steps[{title,description,icon}]` | idem | `icon` default `bi-info-circle` |
| `contentRichCards` | `cards[{icon,title,description,link}]` | idem | `fa-`→`bi-` |
| `contentRichInteractiveTable` | `title`, `subtitle`, `tableTitle`, `tableContent`, `showSearch` (`true`), `textAlignment` (`left`), `tableWidth` (`100%`), `striped` (`true`), `hasHeader` (`true`) | todos | `tableWidth` e `tableTitle` entram sem escape no HTML final; `textAlignment` é mapeado para classes fixas |
| `contentRichCommandLine` | `leftButtons`, `rightButtons`, `backgroundColor` (`#ffffff`) | idem | `align` por botão é usado só na inserção; `style` aceita `padrao`/qualquer outro → `limpo` |
| `contentRichCarousel` | `title`, `subtitle`, `backgroundImageUrl`, `items[{icon,title,text}]` | idem | `text` é HTML cru; `backgroundImageUrl` entra em `url('/storage/…')` |
| `contentRichHtmlFree` | `html` | `html` | cru, por design |
| `contentRichYoutube` | `url` | `url` | playlist tem precedência sobre vídeo (`preg_match` de `list=` primeiro) |

### 3.5 Como adicionar um nó novo (receita de extensão)

1. **JS — schema.** Criar `Assets/js/nodes/content-rich-foo.js` com
   `const ContentRichFooNode = ContentRichEditorDeps.Node.create({ name: 'contentRichFoo', group: 'block', atom: true, addAttributes() {...}, parseHTML() { return [{ tag: 'div[data-lotep-foo]' }] }, renderHTML({HTMLAttributes}) {...}, addNodeView() {...}, addCommands() { return { insertContentRichFoo: options => ... } } })`.
   O `name` **precisa** ser igual ao `type` do PHP.
2. **Bundle.** Adicionar o arquivo ao array `NODES` de `V2/Services/ContentRichEditor/Assets/build-bundle.sh:22-38`, antes
   dos `LOGIC`, e regerar `public/assets/js/content-rich-editor-bundle.js`.
3. **Extensões.** Adicionar `if (typeof ContentRichFooNode !== 'undefined') extensions.push(ContentRichFooNode);`
   em `V2/Services/ContentRichEditor/Assets/js/logic/editor-extensions.js:76-92` (guarda evita quebrar se o arquivo não
   estiver no bundle).
4. **Toolbar.** Criar `case 'insertFoo'` em `Assets/js/logic/editor-commands.js` (prompt/modal +
   `editor.commands.insertContentRichFoo(...)`) e o `<a data-command="insertFoo">` no dropdown
   "Inserir" do Blade (`V2/Services/ContentRichEditor/Views/components/forms/content-rich-editor.blade.php:95-126`).
5. **PHP — renderer.** Criar `Parser/Nodes/ContentRichFooRenderer.php` implementando
   `NodeRendererInterface`, lendo `$node['attrs']` e escapando tudo que for texto/atributo com
   `htmlspecialchars(..., ENT_QUOTES, 'UTF-8')`/`e()`. Retornar `''` quando faltar dado
   obrigatório.
6. **Registro.** `$this->addNodeRenderer('contentRichFoo', function ($node, $content) { return (new Nodes\ContentRichFooRenderer())->render($node, $content); });`
   em `V2/Services/ContentRichEditor/Parser/ContentASTParser.php:119-256`.
7. **Markdown (opcional).** Sobrescrever em `V2/Services/ContentRichEditor/Renderers/MarkdownStrategy.php:25-78`; sem isso, o
   nó sai como HTML.
8. **CSS.** Adicionar seletores em `Views/components/partials/viewer-styles.blade.php` (portal)
   e, se o nó precisa de aparência no editor, em `V2/Services/ContentRichEditor/Views/components/forms/content-rich-editor.blade.php:241-421`.
9. **Contrato.** Documentar `attrs` aqui e no `spec.md §6.1`; testar JSON vazio, atributo
   faltando e HTML no atributo.

### 3.6 Armadilhas específicas

- **Registry é por instância.** `addNodeRenderer` em um `ContentASTParser` não afeta outros; o
  serviço cria um parser novo por chamada. Para estender globalmente, é preciso alterar a
  classe ou criar um serviço próprio.
- **Sem cache de parser.** 33 closures (26 nós + 7 marks) são registradas a cada `toHtml`.
- **Marks antes do nó.** Um renderer de nó recebe conteúdo já marcado; para inspecionar marks
  é preciso olhar `$node['content'][*]['marks']`, não a string.
- **`doc` pode ser sobrescrito** via `addNodeRenderer('doc', …)`; o `parse()` continua exigindo
  a raiz `doc`.
- **`text` com `content`** ignora `text` (o `elseif` em `:57-60`).
- **Escapes mistos.** Alguns renderers usam `e()`, outros `htmlspecialchars(ENT_QUOTES)`; os
  dois produzem o mesmo resultado para HTML, mas `e()` não protege contexto JS (`onclick` de
  PDF, `:39`).
- **`uniqid()`/`str_random()`** tornam a saída não determinística; testes de snapshot precisam
  normalizar ids.

## 4. Integrabilidade

### 4.1 API pública

| Símbolo | Assinatura | Retorno | Precondição | Consumidores |
|---|---|---|---|---|
| `ContentRichEditorRendererService::toHtml` | `toHtml($ast): string` | HTML | AST string/array | todos os módulos |
| `ContentRichEditorRendererService::toMarkdown` | `toMarkdown($ast): string` | Markdown | idem | nenhum uso |
| `ContentASTParser::__construct` | `__construct()` | — | — | strategies |
| `ContentASTParser::parse` | `parse(array $ast): string` | HTML | raiz `doc` | strategies |
| `ContentASTParser::renderNode` | `renderNode(array $node): string` | HTML | nó array | interno |
| `ContentASTParser::renderMark` | `renderMark(array $mark, string $content): string` | HTML | mark array | interno |
| `ContentASTParser::addNodeRenderer` | `addNodeRenderer(string $type, callable $callable)` | void | callable `(array,string):string` | `MarkdownStrategy` |
| `ContentASTParser::addMarkRenderer` | `addMarkRenderer(string $type, callable $callable)` | void | idem | `MarkdownStrategy` |
| `HtmlStrategy::render` | `render(array $ast): string` | HTML | — | serviço |
| `MarkdownStrategy::render` | `render(array $ast): string` | Markdown `trim()` | — | serviço |
| `ContentRichLinkRenderer::urlFor` | `urlFor($model, $slug, $id): string` | URL relativa/`''` | — | `V2/Services/ContentRichEditor/Views/components/iframe-summary.blade.php:24` |
| `NodeRendererInterface::render` | `render(array $node, string $content): string` | HTML | — | 26 classes |
| `MarkRendererInterface::render` | `render(array $mark, string $content): string` | HTML | — | 7 classes |

### 4.2 Receitas de uso

**Renderizar com escape de resumo:**

```php
$html = app(\V2\Services\ContentRichEditor\Services\ContentRichEditorRendererService::class)->toHtml($model->content);
$resumo = \Illuminate\Support\Str::limit(strip_tags($html), 180);
```

**Estender a saída HTML só para um ponto:**

```php
$parser = new \V2\Services\ContentRichEditor\Parser\ContentASTParser();
$parser->addNodeRenderer('contentRichFaq', function (array $node, string $content) {
    $items = $node['attrs']['items'] ?? [];
    return view('meu-modulo::faq', ['items' => $items])->render();
});
$html = $parser->parse($ast);
```

**Resolver URL de conteúdo relacionado sem renderizar:**

```php
$href = \V2\Services\ContentRichEditor\Parser\Nodes\ContentRichLinkRenderer::urlFor('noticias', 'titulo-da-noticia', '');
// => '/noticia/titulo-da-noticia'
```

### 4.3 Anti-uso

1. **Confiar em `toHtml` como sanitizador.** Quatro nós emitem HTML cru
   (`contentRichHtmlFree`, `answer` do FAQ, `text` do carrossel, `html`).
2. **Passar AST parcialmente decodificada.** `json_decode` sem `true` gera `stdClass`, e
   `ensureArray` devolve `[]` — conteúdo desaparece sem erro.
3. **Passar filho não-array.** Provoca `TypeError` não capturado (ver §3.3).
4. **Sobrescrever `contentRichIframe` sem manter a view.** O card depende de
   `v2_public_search_index`; um renderer alternativo deve tratar o caso "registro ausente".
5. **Usar `toMarkdown` esperando paridade.** Listas ordenadas, citação, links e underline saem
   como HTML.

## 5. Rotas e endpoints

Não se aplica a este documento: o mapeamento de nós e renderers é interno ao processo PHP; as
duas rotas do serviço estão em [spec.md](spec.md) §5.

## 6. Dados e migrations

Não se aplica: nenhuma classe deste documento cria tabela, model ou migration. O único acesso
a dados é a leitura de `v2_public_search_index` por `ContentRichIframeRenderer`
(`V2/Services/ContentRichEditor/Views/components/iframe-summary.blade.php:12-16`) e o formato persistido está descrito em
[spec.md](spec.md) §6.1.

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

Não se aplica: nenhuma chave de configuração é consultada pelos renderers. Cores e fontes vêm
de variáveis CSS (`--tenant-primary`, `--tenant-accent`, `--color-text-main`) com fallback
literal no HTML.

## 8. Observabilidade e erros

| Ponto | Comportamento |
|---|---|
| `iframe-summary` (DB) | `try/catch (\Exception)` → `Log::warning('[ContentRichEditor] Falha ao carregar iframe-summary', …)` e card fallback (`V2/Services/ContentRichEditor/Views/components/iframe-summary.blade.php:26-28`). |
| `content-rich-viewer` | `try/catch (\Exception)` → `Log::error('[ContentRichEditor] Falha ao renderizar AST', …)` e nada é impresso (`V2/Services/ContentRichEditor/Views/components/content-rich-viewer.blade.php:11-15`). |
| Módulos | Cada consumidor repete o `try/catch` e decide o fallback (ex.: `Parceiro::getHtmlDescription()` devolve `e($this->description)`). |
| `TypeError` em AST malformada | Não é `\Exception`; derruba a request (500), sem log do serviço. |
| Markdown | Sem log, sem validação. |

## 9. Consumidores

| Consumidor | O que usa do mapa | Risco |
|---|---|---|
| Notícias | todos os nós; preview via `toHtml` | alto |
| SobreNos (bio) | HTML de todos os nós | médio |
| Parceiros/Unidade Regional/História/Associados/AssocieSe/Trabalhe Conosco/Agenda | HTML de todos os nós | médio |
| LandingSectionsEditor | `toHtml` em 11 renders | alto |
| `iframe-summary` | `ContentRichLinkRenderer::urlFor` | médio |
| Bundle JS | deve espelhar os 28 `type`s | alto (paridade manual) |

## 10. Lacunas

| ID | Lacuna | Impacto | Onde procurar |
|---|---|---|---|
| LG-CONTENTRICHEDITOR-12 | `hardBreak` e `codeBlock` do StarterKit não têm renderer; mark `code` idem. | Conteúdo digitado com Shift+Enter ou bloco de código perde semântica no portal. | `V2/Services/ContentRichEditor/Parser/ContentASTParser.php:119-256` |
| LG-CONTENTRICHEDITOR-13 | Tabelas nativas renderizam `th` dentro de `tbody` e nunca emitem `thead`. | HTML inválido e leitores de tela não anunciam cabeçalho de tabela corretamente. | `V2/Services/ContentRichEditor/Parser/Nodes/TableRenderer.php:16-19`; `V2/Services/ContentRichEditor/Parser/Nodes/TableHeaderRenderer.php:16-30` |
| LG-CONTENTRICHEDITOR-14 | `TypeError` em AST malformada não é capturado. | Página 500 por conteúdo corrompido. | `V2/Services/ContentRichEditor/Parser/ContentASTParser.php:49`; `V2/Services/ContentRichEditor/Views/components/content-rich-viewer.blade.php:11` |
| LG-CONTENTRICHEDITOR-15 | `MarkdownStrategy` cobre 11 de 28 tipos e não tem consumidor. | Exportação inconsistente. | `V2/Services/ContentRichEditor/Renderers/MarkdownStrategy.php:25-78` |
| LG-CONTENTRICHEDITOR-16 | `urlFor` não valida `model` (default monta `/{model}/{slug}`). | Links quebrados para módulos novos. | `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichLinkRenderer.php:76-77` |
| LG-CONTENTRICHEDITOR-17 | `tableWidth` e nomes de ícone entram em HTML sem validação de formato. | CSS injetável por autor; ícone inválido gera classe arbitrária. | `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichInteractiveTableRenderer.php:25,106`; `V2/Services/ContentRichEditor/Parser/Nodes/ContentRichCardsRenderer.php:29-34` |
| LG-CONTENTRICHEDITOR-18 | Sem testes de paridade JS↔PHP. | Divergências como CTA/iframe só aparecem em produção. | `find V2/Services/ContentRichEditor -iname '*test*'` → vazio |

## 11. Checklist de paridade

- [ ] `PC-CONTENTRICHEDITOR-21` — Para cada um dos 26 `type`s registrados, existe um nó correspondente no bundle (exceto `doc`/nativos) e a saída HTML é a da tabela §3.2.
- [ ] `PC-CONTENTRICHEDITOR-22` — `bold`/`italic`/`strike`/`underline`/`highlight`/`outdated`/`link` produzem exatamente as tags da tabela §3.2; a ordem de aplicação é a ordem do array `marks`.
- [ ] `PC-CONTENTRICHEDITOR-23` — `paragraph`/`heading` com `textAlign` inválido não geram `style`; com `indent` não numérico também não.
- [ ] `PC-CONTENTRICHEDITOR-24` — `heading` nível 5 (fora do editor) renderiza `<h5>`; nível ausente cai em `2`.
- [ ] `PC-CONTENTRICHEDITOR-25` — Nó desconhecido preserva filhos; mark desconhecida preserva conteúdo.
- [ ] `PC-CONTENTRICHEDITOR-26` — `contentRichMedia` com `variants.original` usa esse caminho; sem `variants`, prefixa `/storage/` uma única vez.
- [ ] `PC-CONTENTRICHEDITOR-27` — `contentRichYoutube` extrai ID de `watch?v=`, `youtu.be/` e `embed/`; playlist `list=` tem precedência.
- [ ] `PC-CONTENTRICHEDITOR-28` — `contentRichFaq`/`contentRichCarousel`/`contentRichInteractiveTable` geram ids únicos por render e não colidem em duas ocorrências.
- [ ] `PC-CONTENTRICHEDITOR-29` — `toMarkdown` de documento com parágrafo, h2, lista com marcador, bold, italic, strike, outdated, mídia, iframe e cross-link devolve o esperado; orderedList/blockquote saem como HTML.
- [ ] `PC-CONTENTRICHEDITOR-30` — AST malformada `{"type":"doc","content":["x"]}` deve ser tratada (hoje: `TypeError`) — decidir se o comportamento é capturar e devolver `""`.
- [ ] `PC-CONTENTRICHEDITOR-31` — `addNodeRenderer` sobrescreve um tipo sem alterar os demais e sem afetar outra instância de parser.

## 12. Evidências consultadas

- `V2/Services/ContentRichEditor/Parser/ContentASTParser.php:1-258`; `V2/Services/ContentRichEditor/Parser/NodeRendererInterface.php:1-8`; `V2/Services/ContentRichEditor/Parser/MarkRendererInterface.php:1-8`.
- `Parser/Nodes/` (26 classes) e `Parser/Marks/` (7 classes) — leitura integral.
- `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/Assets/js/nodes/*.js` (14) e `V2/Services/ContentRichEditor/Assets/js/marks/content-rich-outdated.js:1-19` — schema, `parseHTML`, `renderHTML`, `addNodeView`, `addCommands`.
- `V2/Services/ContentRichEditor/Assets/js/logic/editor-extensions.js:1-95`; `V2/Services/ContentRichEditor/Assets/js/logic/editor-commands.js:1-293`; `V2/Services/ContentRichEditor/Assets/build-bundle.sh:1-72`.
- `V2/Services/ContentRichEditor/Views/components/iframe-summary.blade.php:1-42`; `V2/Services/ContentRichEditor/Views/components/content-rich-viewer.blade.php:1-25`; `V2/Services/ContentRichEditor/Views/components/partials/viewer-styles.blade.php:1-697` (seletores e blocos por componente).
- Consumidores citados em §9 e `V2/Services/LandingSectionsEditor/Views/render/*.blade.php`.
