144 lines
8.1 KiB
PHP
144 lines
8.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/** Lightweight Markdown renderer. Raw HTML is escaped and images stay local. */
|
|
final class PublicContentFormatter
|
|
{
|
|
public static function render(string $text): string
|
|
{
|
|
$lines = preg_split('/\R/u', trim($text)) ?: [];
|
|
$html = [];
|
|
$count = count($lines);
|
|
for ($i = 0; $i < $count;) {
|
|
$line = rtrim($lines[$i]);
|
|
if (trim($line) === '') { $i++; continue; }
|
|
|
|
if (preg_match('/^\s*```\s*([A-Za-z0-9_-]*)\s*$/u', $line, $fence)) {
|
|
$language = strtolower($fence[1]);
|
|
$code = [];
|
|
for ($i++; $i < $count && !preg_match('/^\s*```\s*$/u', $lines[$i]); $i++) $code[] = $lines[$i];
|
|
if ($i < $count) $i++;
|
|
$class = $language !== '' ? ' class="language-' . htmlspecialchars($language, ENT_QUOTES, 'UTF-8') . '"' : '';
|
|
$html[] = '<pre><code' . $class . '>' . htmlspecialchars(implode("\n", $code), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</code></pre>';
|
|
continue;
|
|
}
|
|
|
|
if ($i + 1 < $count && self::isTableRow($line) && self::isTableSeparator($lines[$i + 1])) {
|
|
$headers = self::tableCells($line);
|
|
$alignments = array_map([self::class, 'tableAlignment'], self::tableCells($lines[$i + 1]));
|
|
$rows = [];
|
|
for ($i += 2; $i < $count && self::isTableRow($lines[$i]) && trim($lines[$i]) !== ''; $i++) $rows[] = self::tableCells($lines[$i]);
|
|
$table = '<div class="public-markdown-table"><table><thead><tr>';
|
|
foreach ($headers as $column => $header) {
|
|
$align = $alignments[$column] ?? '';
|
|
$table .= '<th' . ($align ? ' class="text-' . $align . '"' : '') . '>' . self::inline($header) . '</th>';
|
|
}
|
|
$table .= '</tr></thead><tbody>';
|
|
foreach ($rows as $row) {
|
|
$table .= '<tr>';
|
|
foreach ($headers as $column => $_) {
|
|
$align = $alignments[$column] ?? '';
|
|
$table .= '<td' . ($align ? ' class="text-' . $align . '"' : '') . '>' . self::inline($row[$column] ?? '') . '</td>';
|
|
}
|
|
$table .= '</tr>';
|
|
}
|
|
$html[] = $table . '</tbody></table></div>';
|
|
continue;
|
|
}
|
|
|
|
if (preg_match('/^\s*([-*_])(?:\s*\1){2,}\s*$/u', $line)) { $html[] = '<hr>'; $i++; continue; }
|
|
if (preg_match('/^(#{1,6})\s+(.+?)\s*#*$/u', trim($line), $heading)) {
|
|
$level = min(6, strlen($heading[1]) + 1);
|
|
$html[] = '<h' . $level . '>' . self::inline($heading[2]) . '</h' . $level . '>';
|
|
$i++;
|
|
continue;
|
|
}
|
|
if (preg_match('/^\s*>\s?(.*)$/u', $line)) {
|
|
$quote = [];
|
|
while ($i < $count && preg_match('/^\s*>\s?(.*)$/u', $lines[$i], $match)) { $quote[] = $match[1]; $i++; }
|
|
$html[] = '<blockquote>' . self::render(implode("\n", $quote)) . '</blockquote>';
|
|
continue;
|
|
}
|
|
if (preg_match('/^\s*(?:[-+*]|\d+[.)])\s+(.+)$/u', $line)) {
|
|
$ordered = (bool) preg_match('/^\s*\d+[.)]\s+/u', $line);
|
|
$tag = $ordered ? 'ol' : 'ul';
|
|
$list = '<' . $tag . '>';
|
|
while ($i < $count) {
|
|
$pattern = $ordered ? '/^\s*\d+[.)]\s+(.+)$/u' : '/^\s*[-+*]\s+(.+)$/u';
|
|
if (!preg_match($pattern, $lines[$i], $item)) break;
|
|
$value = $item[1];
|
|
$taskClass = '';
|
|
if (preg_match('/^\[([ xX])\]\s+(.+)$/u', $value, $task)) {
|
|
$taskClass = ' class="task-list-item"';
|
|
$value = '<span class="task-marker" aria-hidden="true">' . ($task[1] === ' ' ? '☐' : '☑') . '</span> ' . self::inline($task[2]);
|
|
} else $value = self::inline($value);
|
|
$list .= '<li' . $taskClass . '>' . $value . '</li>';
|
|
$i++;
|
|
}
|
|
$html[] = $list . '</' . $tag . '>';
|
|
continue;
|
|
}
|
|
|
|
$paragraph = [trim($line)];
|
|
for ($i++; $i < $count && trim($lines[$i]) !== '' && !self::startsBlock($lines, $i); $i++) $paragraph[] = trim($lines[$i]);
|
|
$html[] = '<p>' . self::inline(implode(' ', $paragraph)) . '</p>';
|
|
}
|
|
return implode("\n", $html);
|
|
}
|
|
|
|
private static function startsBlock(array $lines, int $index): bool
|
|
{
|
|
$line = rtrim((string) ($lines[$index] ?? ''));
|
|
return preg_match('/^\s*(?:```|#{1,6}\s+|>\s?|[-+*]\s+|\d+[.)]\s+)/u', $line) === 1
|
|
|| preg_match('/^\s*([-*_])(?:\s*\1){2,}\s*$/u', $line) === 1
|
|
|| ($index + 1 < count($lines) && self::isTableRow($line) && self::isTableSeparator($lines[$index + 1]));
|
|
}
|
|
|
|
private static function isTableRow(string $line): bool { return str_contains(trim($line), '|'); }
|
|
private static function isTableSeparator(string $line): bool
|
|
{
|
|
$cells = self::tableCells($line);
|
|
return $cells !== [] && count(array_filter($cells, static fn (string $cell): bool => preg_match('/^:?-{3,}:?$/', trim($cell)) === 1)) === count($cells);
|
|
}
|
|
private static function tableCells(string $line): array
|
|
{
|
|
$line = trim($line);
|
|
if (str_starts_with($line, '|')) $line = substr($line, 1);
|
|
if (str_ends_with($line, '|') && !str_ends_with($line, '\\|')) $line = substr($line, 0, -1);
|
|
$marker = "\x1D";
|
|
return array_map(static fn (string $cell): string => str_replace($marker, '|', trim($cell)), explode('|', str_replace('\\|', $marker, $line)));
|
|
}
|
|
private static function tableAlignment(string $separator): string
|
|
{
|
|
$separator = trim($separator);
|
|
if (str_starts_with($separator, ':') && str_ends_with($separator, ':')) return 'center';
|
|
if (str_ends_with($separator, ':')) return 'right';
|
|
return str_starts_with($separator, ':') ? 'left' : '';
|
|
}
|
|
|
|
private static function inline(string $text): string
|
|
{
|
|
$tokens = [];
|
|
$reserve = static function (string $html) use (&$tokens): string {
|
|
$token = "\x1A" . count($tokens) . "\x1A";
|
|
$tokens[] = $html;
|
|
return $token;
|
|
};
|
|
$text = preg_replace_callback('/`([^`]+)`/u', static fn (array $match): string => $reserve('<code>' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</code>'), $text) ?? $text;
|
|
$text = preg_replace_callback('/!\[([^\]]*)\]\((\/media\/site\/[A-Za-z0-9._%-]+)(?:\s+["\']([^"\']*)["\'])?\)/u', static fn (array $match): string => $reserve('<img class="public-content-image" src="' . htmlspecialchars($match[2], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '" alt="' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '" loading="lazy">'), $text) ?? $text;
|
|
$text = preg_replace_callback('/\[([^\]]+)\]\((https:\/\/[^\s)]+|\/[^\s)]*)\)/u', static function (array $match) use ($reserve): string {
|
|
$url = $match[2];
|
|
if (str_starts_with($url, 'https://') && !filter_var($url, FILTER_VALIDATE_URL)) return $match[0];
|
|
return $reserve('<a href="' . htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '"' . (str_starts_with($url, 'https://') ? ' rel="noopener noreferrer"' : '') . '>' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</a>');
|
|
}, $text) ?? $text;
|
|
$text = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
|
$text = preg_replace('/~~(.+?)~~/u', '<del>$1</del>', $text) ?? $text;
|
|
$text = preg_replace('/\*\*(.+?)\*\*/u', '<strong>$1</strong>', $text) ?? $text;
|
|
$text = preg_replace('/__(.+?)__/u', '<strong>$1</strong>', $text) ?? $text;
|
|
$text = preg_replace('/(?<!\*)\*([^*]+)\*(?!\*)/u', '<em>$1</em>', $text) ?? $text;
|
|
$text = preg_replace('/(?<!_)_([^_]+)_(?!_)/u', '<em>$1</em>', $text) ?? $text;
|
|
foreach ($tokens as $index => $html) $text = str_replace("\x1A" . $index . "\x1A", $html, $text);
|
|
return $text;
|
|
}
|
|
}
|