'

' . self::renderNodes($content) . '

', 'heading' => self::renderHeading($attrs, $content), 'bulletList' => '', 'orderedList' => '
    ' . self::renderNodes($content) . '
', 'listItem' => '
  • ' . self::renderNodes($content) . '
  • ', 'blockquote' => '
    ' . self::renderNodes($content) . '
    ', 'codeBlock' => '
    ' . e(self::extractText($content)) . '
    ', 'hardBreak' => '
    ', 'horizontalRule' => '
    ', 'text' => self::renderText($node), 'table' => self::renderTable($content), 'tableRow' => '' . self::renderNodes($content) . '', 'tableCell' => self::renderTableCell($attrs, $content, false), 'tableHeader' => self::renderTableCell($attrs, $content, true), default => self::renderNodes($content), }; } private static function renderHeading(array $attrs, array $content): string { $level = max(1, min(6, (int) ($attrs['level'] ?? 1))); return "" . self::renderNodes($content) . ""; } private static function renderText(array $node): string { $text = e($node['text'] ?? ''); $marks = $node['marks'] ?? []; foreach ($marks as $mark) { $text = self::applyMark($mark, $text); } return $text; } private static function applyMark(array $mark, string $text): string { $type = $mark['type'] ?? ''; $attrs = $mark['attrs'] ?? []; return match ($type) { 'bold' => "{$text}", 'italic' => "{$text}", 'code' => "{$text}", 'strike' => "{$text}", 'underline' => "{$text}", 'link' => self::renderLink($attrs, $text), default => $text, }; } private static function renderLink(array $attrs, string $text): string { $href = e($attrs['href'] ?? ''); if (preg_match('/^\s*javascript:/i', rawurldecode($href))) { $href = '#'; } $target = isset($attrs['target']) ? ' target="' . e($attrs['target']) . '"' : ''; $rel = $target ? ' rel="noopener noreferrer"' : ''; return "{$text}"; } private static function renderTable(array $rows): string { if (empty($rows)) { return '
    '; } // If the first row consists entirely of tableHeader cells, put it in $firstCells = $rows[0]['content'] ?? []; $headerCount = count(array_filter($firstCells, fn($c) => ($c['type'] ?? '') === 'tableHeader')); $firstIsHeaderRow = !empty($firstCells) && $headerCount === count($firstCells); if ($firstIsHeaderRow) { $thead = '' . self::renderNode($rows[0]) . ''; $tbody = count($rows) > 1 ? '' . self::renderNodes(array_slice($rows, 1)) . '' : ''; return "{$thead}{$tbody}
    "; } return '' . self::renderNodes($rows) . '
    '; } private static function renderTableCell(array $attrs, array $content, bool $header): string { $tag = $header ? 'th' : 'td'; $extra = ''; if (!empty($attrs['colspan']) && $attrs['colspan'] > 1) { $extra .= ' colspan="' . (int) $attrs['colspan'] . '"'; } if (!empty($attrs['rowspan']) && $attrs['rowspan'] > 1) { $extra .= ' rowspan="' . (int) $attrs['rowspan'] . '"'; } // TipTap wraps cell content in a paragraph node; unwrap single paragraphs if (count($content) === 1 && ($content[0]['type'] ?? '') === 'paragraph') { $inner = self::renderNodes($content[0]['content'] ?? []); } else { $inner = self::renderNodes($content); } return "<{$tag}{$extra}>{$inner}"; } private static function extractText(array $nodes): string { return implode('', array_map( fn($node) => $node['text'] ?? self::extractText($node['content'] ?? []), $nodes )); } }