'
' . self::renderNodes($content) . '
',
'heading' => self::renderHeading($attrs, $content),
'bulletList' => '' . self::renderNodes($content) . '
',
'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 "";
}
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}{$tag}>";
}
private static function extractText(array $nodes): string
{
return implode('', array_map(
fn($node) => $node['text'] ?? self::extractText($node['content'] ?? []),
$nodes
));
}
}