' . htmlspecialchars(implode("\n", $code), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; 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 = '
'; foreach ($headers as $column => $header) { $align = $alignments[$column] ?? ''; $table .= '' . self::inline($header) . ''; } $table .= ''; foreach ($rows as $row) { $table .= ''; foreach ($headers as $column => $_) { $align = $alignments[$column] ?? ''; $table .= '' . self::inline($row[$column] ?? '') . ''; } $table .= ''; } $html[] = $table . '
'; continue; } if (preg_match('/^\s*([-*_])(?:\s*\1){2,}\s*$/u', $line)) { $html[] = '
'; $i++; continue; } if (preg_match('/^(#{1,6})\s+(.+?)\s*#*$/u', trim($line), $heading)) { $level = min(6, strlen($heading[1]) + 1); $html[] = '' . self::inline($heading[2]) . ''; $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[] = '
' . self::render(implode("\n", $quote)) . '
'; 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 = ' ' . self::inline($task[2]); } else $value = self::inline($value); $list .= '' . $value . ''; $i++; } $html[] = $list . ''; continue; } $paragraph = [trim($line)]; for ($i++; $i < $count && trim($lines[$i]) !== '' && !self::startsBlock($lines, $i); $i++) $paragraph[] = trim($lines[$i]); $html[] = '

' . self::inline(implode(' ', $paragraph)) . '

'; } 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('' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''), $text) ?? $text; $text = preg_replace_callback('/!\[([^\]]*)\]\((\/media\/site\/[A-Za-z0-9._%-]+)(?:\s+["\']([^"\']*)["\'])?\)/u', static fn (array $match): string => $reserve('' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''), $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('' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''); }, $text) ?? $text; $text = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $text = preg_replace('/~~(.+?)~~/u', '$1', $text) ?? $text; $text = preg_replace('/\*\*(.+?)\*\*/u', '$1', $text) ?? $text; $text = preg_replace('/__(.+?)__/u', '$1', $text) ?? $text; $text = preg_replace('/(?$1', $text) ?? $text; $text = preg_replace('/(?$1', $text) ?? $text; foreach ($tokens as $index => $html) $text = str_replace("\x1A" . $index . "\x1A", $html, $text); return $text; } }