186 lines
6.0 KiB
PHP
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));
|
|
}
|
|
}
|