This commit is contained in:
Sven Steinert
2026-05-13 11:57:52 +02:00
parent 6abf6f9c3d
commit f4511b9213
76 changed files with 4494 additions and 1940 deletions

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace KbMarkdownImporter\Markdown;
use KbMarkdownImporter\Frontend\UrlBuilder;
final class MarkdownRenderer
{
public function render(string $markdown, array $context = []): string
{
$lines = preg_split('/\R/', str_replace(["\r\n", "\r"], "\n", $markdown)) ?: [];
$html = [];
$paragraph = [];
$listType = '';
$codeFence = false;
$code = [];
$flushParagraph = function () use (&$html, &$paragraph, $context): void {
if (! $paragraph) {
return;
}
$html[] = '<p>' . $this->renderInline(trim(implode(' ', $paragraph)), $context) . '</p>';
$paragraph = [];
};
$closeList = function () use (&$html, &$listType): void {
if ('' === $listType) {
return;
}
$html[] = '</' . $listType . '>';
$listType = '';
};
foreach ($lines as $line) {
if (preg_match('/^\s*```/', $line)) {
if ($codeFence) {
$html[] = '<pre><code>' . esc_html(implode("\n", $code)) . '</code></pre>';
$code = [];
$codeFence = false;
} else {
$flushParagraph();
$closeList();
$codeFence = true;
}
continue;
}
if ($codeFence) {
$code[] = $line;
continue;
}
if ('' === trim($line)) {
$flushParagraph();
$closeList();
continue;
}
if (preg_match('/^(#{1,6})\s+(.+)$/', $line, $matches)) {
$flushParagraph();
$closeList();
$level = strlen($matches[1]);
$text = trim($matches[2]);
$id = sanitize_title(wp_strip_all_tags($text));
$html[] = sprintf('<h%d id="%s">%s</h%d>', $level, esc_attr($id), $this->renderInline($text, $context), $level);
continue;
}
if (preg_match('/^\s*[-*]\s+(.+)$/', $line, $matches)) {
$flushParagraph();
if ('ul' !== $listType) {
$closeList();
$html[] = '<ul>';
$listType = 'ul';
}
$html[] = '<li>' . $this->renderInline(trim($matches[1]), $context) . '</li>';
continue;
}
if (preg_match('/^\s*\d+\.\s+(.+)$/', $line, $matches)) {
$flushParagraph();
if ('ol' !== $listType) {
$closeList();
$html[] = '<ol>';
$listType = 'ol';
}
$html[] = '<li>' . $this->renderInline(trim($matches[1]), $context) . '</li>';
continue;
}
if (preg_match('/^>\s?(.*)$/', $line, $matches)) {
$flushParagraph();
$closeList();
$html[] = '<blockquote><p>' . $this->renderInline(trim($matches[1]), $context) . '</p></blockquote>';
continue;
}
$paragraph[] = trim($line);
}
if ($codeFence) {
$html[] = '<pre><code>' . esc_html(implode("\n", $code)) . '</code></pre>';
}
$flushParagraph();
$closeList();
return wp_kses_post(implode("\n", $html));
}
private function renderInline(string $text, array $context): string
{
$escaped = esc_html($text);
$escaped = preg_replace_callback('/!\[([^\]]*)\]\(([^)]+)\)/', function (array $matches) use ($context): string {
$alt = html_entity_decode($matches[1], ENT_QUOTES);
$src = html_entity_decode($matches[2], ENT_QUOTES);
$url = $this->resolveImageUrl($src, (array) ($context['images'] ?? []));
$image = sprintf('<img src="%s" alt="%s">', esc_url($url ?: $src), esc_attr($alt));
if ($url && ! empty($context['lightbox'])) {
return sprintf('<a href="%s" class="kb-lightbox">%s</a>', esc_url($url), $image);
}
return $image;
}, $escaped) ?? $escaped;
$escaped = preg_replace_callback('/(?<!!)\[([^\]]+)\]\(([^)]+)\)/', function (array $matches) use ($context): string {
$label = $this->renderInline($matches[1], $context);
$href = html_entity_decode($matches[2], ENT_QUOTES);
$url = $this->rewriteLink($href, $context) ?: $href;
return sprintf('<a href="%s">%s</a>', esc_url($url), $label);
}, $escaped) ?? $escaped;
$escaped = preg_replace('/`([^`]+)`/', '<code>$1</code>', $escaped) ?? $escaped;
$escaped = preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $escaped) ?? $escaped;
$escaped = preg_replace('/\*([^*]+)\*/', '<em>$1</em>', $escaped) ?? $escaped;
return $escaped;
}
private function rewriteLink(string $href, array $context): string
{
if ('' === $href || str_starts_with($href, '#') || preg_match('#^(?:https?:|mailto:|tel:)#i', $href)) {
return '';
}
$parts = wp_parse_url($href);
if (! is_array($parts)) {
return '';
}
$path = (string) ($parts['path'] ?? '');
if (! preg_match('/\.md$/i', $path)) {
return '';
}
$page = preg_replace('/\.md$/i', '', basename($path)) ?: basename($path);
$slug = in_array(strtolower($page), ['doku', 'index'], true) ? '' : sanitize_title($page);
$fragment = isset($parts['fragment']) ? '#' . sanitize_title((string) $parts['fragment']) : '';
return UrlBuilder::page((string) $context['product_slug'], (string) $context['version_slug'], $slug) . $fragment;
}
private function resolveImageUrl(string $src, array $images): string
{
if ('' === $src || preg_match('#^(?:https?:|data:)#i', $src)) {
return '';
}
$candidates = array_unique([
$src,
ltrim($src, '/'),
basename($src),
preg_replace('#^images/#', '', $src) ?: $src,
]);
foreach ($candidates as $candidate) {
if (isset($images[$candidate])) {
return (string) $images[$candidate];
}
}
return '';
}
}