proofdb/app/service/AdminConsole/MarkdownRenderer.php
2026-05-08 00:05:51 +08:00

186 lines
6.0 KiB
PHP

<?php
namespace app\service\AdminConsole;
class MarkdownRenderer
{
public function render(string $markdown): string
{
$lines = preg_split('/\r\n|\n|\r/', $markdown) ?: [];
$html = [];
$paragraph = [];
$listType = null;
$table = null;
$inCodeBlock = false;
$codeLines = [];
$flushParagraph = function () use (&$paragraph, &$html): void {
if ($paragraph === []) {
return;
}
$text = implode(' ', array_map('trim', $paragraph));
$html[] = '<p>' . $this->renderInline($text) . '</p>';
$paragraph = [];
};
$flushList = function () use (&$listType, &$html): void {
if ($listType !== null) {
$html[] = '</' . $listType . '>';
$listType = null;
}
};
$flushTable = function () use (&$table, &$html): void {
if ($table === null) {
return;
}
$html[] = '<table class="markdown-table"><thead><tr>' .
implode('', array_map(fn (string $cell): string => '<th>' . $this->renderInline($cell) . '</th>', $table['headers'])) .
'</tr></thead><tbody>';
foreach ($table['rows'] as $row) {
$html[] = '<tr>' .
implode('', array_map(fn (string $cell): string => '<td>' . $this->renderInline($cell) . '</td>', $row)) .
'</tr>';
}
$html[] = '</tbody></table>';
$table = null;
};
foreach ($lines as $line) {
if (preg_match('/^```/', $line)) {
$flushParagraph();
$flushList();
$flushTable();
if ($inCodeBlock) {
$html[] = '<pre class="markdown-pre"><code>' . htmlspecialchars(implode("\n", $codeLines), ENT_QUOTES, 'UTF-8') . '</code></pre>';
$codeLines = [];
$inCodeBlock = false;
} else {
$inCodeBlock = true;
}
continue;
}
if ($inCodeBlock) {
$codeLines[] = $line;
continue;
}
$trimmed = trim($line);
if ($trimmed === '') {
$flushParagraph();
$flushList();
$flushTable();
continue;
}
if (preg_match('/^(#{1,6})\s+(.+)$/', $trimmed, $matches)) {
$flushParagraph();
$flushList();
$flushTable();
$level = strlen($matches[1]);
$html[] = sprintf('<h%d>%s</h%d>', $level, $this->renderInline($matches[2]), $level);
continue;
}
if (preg_match('/^>\s?(.+)$/', $trimmed, $matches)) {
$flushParagraph();
$flushList();
$flushTable();
$html[] = '<blockquote>' . $this->renderInline($matches[1]) . '</blockquote>';
continue;
}
if (preg_match('/^---+$/', $trimmed)) {
$flushParagraph();
$flushList();
$flushTable();
$html[] = '<hr>';
continue;
}
if ($this->isTableDelimiter($trimmed) && $table !== null) {
continue;
}
if (str_contains($trimmed, '|')) {
$cells = $this->tableCells($trimmed);
if (count($cells) >= 2) {
$flushParagraph();
$flushList();
if ($table === null) {
$table = ['headers' => $cells, 'rows' => []];
} else {
$table['rows'][] = $cells;
}
continue;
}
}
if (preg_match('/^[-*]\s+(.+)$/', $trimmed, $matches)) {
$flushParagraph();
$flushTable();
if ($listType !== 'ul') {
$flushList();
$listType = 'ul';
$html[] = '<ul>';
}
$html[] = '<li>' . $this->renderInline($matches[1]) . '</li>';
continue;
}
if (preg_match('/^\d+\.\s+(.+)$/', $trimmed, $matches)) {
$flushParagraph();
$flushTable();
if ($listType !== 'ol') {
$flushList();
$listType = 'ol';
$html[] = '<ol>';
}
$html[] = '<li>' . $this->renderInline($matches[1]) . '</li>';
continue;
}
$flushList();
$flushTable();
$paragraph[] = $trimmed;
}
if ($inCodeBlock) {
$html[] = '<pre class="markdown-pre"><code>' . htmlspecialchars(implode("\n", $codeLines), ENT_QUOTES, 'UTF-8') . '</code></pre>';
}
$flushParagraph();
$flushList();
$flushTable();
return implode("\n", $html);
}
private function renderInline(string $text): string
{
$text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
$text = preg_replace('/`([^`]+)`/', '<code>$1</code>', $text) ?? $text;
$text = preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $text) ?? $text;
$text = preg_replace('/\*([^*]+)\*/', '<em>$1</em>', $text) ?? $text;
$text = preg_replace('/\[(.+?)\]\((.+?)\)/', '<a href="$2" target="_blank" rel="noreferrer">$1</a>', $text) ?? $text;
return $text;
}
private function isTableDelimiter(string $line): bool
{
return (bool) preg_match('/^\|?[\s:-]+\|[\s|:-]*$/', $line);
}
private function tableCells(string $line): array
{
$line = trim($line);
$line = trim($line, '|');
return array_map(static fn (string $cell): string => trim($cell), explode('|', $line));
}
}