Étendre le Markdown du site public

This commit is contained in:
Alexandre NOEL 2026-10-12 10:59:11 +02:00
commit 56a2fddc83
12 changed files with 179 additions and 52 deletions

View file

@ -2,77 +2,143 @@
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 = [];
$listOpen = false;
$count = count($lines);
for ($i = 0; $i < $count;) {
$line = rtrim($lines[$i]);
if (trim($line) === '') { $i++; continue; }
foreach ($lines as $line) {
$line = trim($line);
if ($line === '') {
if ($listOpen) {
$html[] = '</ul>';
$listOpen = false;
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>';
}
continue;
}
if (preg_match('/^[-*]\s+(.+)$/u', $line, $match)) {
if (!$listOpen) {
$html[] = '<ul>';
$listOpen = true;
$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[] = '<li>' . self::inline($match[1]) . '</li>';
$html[] = $table . '</tbody></table></div>';
continue;
}
if ($listOpen) {
$html[] = '</ul>';
$listOpen = false;
}
if (preg_match('/^(#{1,3})\s+(.+)$/u', $line, $match)) {
$level = strlen($match[1]) + 1;
$html[] = '<h' . $level . '>' . self::inline($match[2]) . '</h' . $level . '>';
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;
}
$html[] = '<p>' . self::inline($line) . '</p>';
}
if ($listOpen) {
$html[] = '</ul>';
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
{
$images = [];
$text = preg_replace_callback('/!\[([^\]]*)\]\((\/media\/site\/[A-Za-z0-9._%-]+)\)/u', static function (array $match) use (&$images): string {
$token = "\x1B" . count($images) . "\x1B";
$images[] = '<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">';
return $token;
}, $text) ?? $text;
$links = [];
$text = preg_replace_callback('/\[([^\]]+)\]\((https:\/\/[^\s)]+)\)/u', static function (array $match) use (&$links): string {
if (!filter_var($match[2], FILTER_VALIDATE_URL)) {
return $match[0];
}
$token = "\x1A" . count($links) . "\x1A";
$links[] = '<a href="' . htmlspecialchars($match[2], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') .
'" rel="noopener noreferrer">' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</a>';
$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;
foreach ($links as $index => $link) {
$text = str_replace("\x1A" . $index . "\x1A", $link, $text);
}
foreach ($images as $index => $image) {
$text = str_replace("\x1B" . $index . "\x1B", $image, $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;
}
}

View file

@ -31,10 +31,10 @@
<div class="col-lg-6">
<section class="card h-100">
<div class="card-body">
<span class="badge bg-blue-lt mb-2">1.0.0-rc.34</span>
<h3>Un site public qui s’adapte au refuge</h3>
<p class="text-muted">Pages personnalisées, contenus longs, panneaux repliables et ordre du menu
réorganisable rendent chaque site aussi simple ou aussi complet que nécessaire.</p>
<span class="badge bg-blue-lt mb-2">1.0.0-rc.35</span>
<h3>Des pages publiques riches et lisibles</h3>
<p class="text-muted">Les pages personnalisées accueillent maintenant tableaux responsives, listes,
citations et code tout en conservant un rendu strictement sécurisé.</p>
</div>
</section>
</div>

View file

@ -265,6 +265,40 @@ if (!function_exists('public_age')) {
border-radius: var(--radius)
}
.public-markdown-table {
max-width: 100%;
margin: 1.25rem 0;
overflow-x: auto;
border: 1px solid var(--line);
border-radius: calc(var(--radius) / 2)
}
.public-markdown-table table {
width: 100%;
min-width: 420px;
border-collapse: collapse;
background: var(--surface)
}
.public-markdown-table th,
.public-markdown-table td {
padding: 11px 14px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top
}
.public-markdown-table th { background: var(--paper); font-weight: 800 }
.public-markdown-table tr:last-child td { border-bottom: 0 }
.public-markdown-table .text-center { text-align: center }
.public-markdown-table .text-right { text-align: right }
blockquote { margin: 1.25rem 0; padding: .75rem 1rem; border-left: 4px solid var(--accent); background: var(--surface) }
blockquote p { margin: 0 }
pre { max-width: 100%; overflow: auto; padding: 1rem; border-radius: calc(var(--radius) / 2); background: var(--ink); color: var(--surface) }
:not(pre)>code { padding: .12rem .35rem; border-radius: 4px; background: var(--line) }
.task-list-item { list-style: none }
.task-marker { color: var(--accent-dark) }
.section {
padding: 52px 0
}

View file

@ -238,7 +238,7 @@ const enhancePublicEditor = (textarea) => {
textarea.addEventListener('focus', () => { activePublicEditor = textarea; });
const toolbar = document.createElement('div');
toolbar.className = 'btn-list mb-2';
const actions = [['B', '**', '**'], ['I', '*', '*'], ['Titre', '## ', ''], ['Liste', '- ', ''], ['Lien', '[', '](https://)']];
const actions = [['B', '**', '**'], ['I', '*', '*'], ['Titre', '## ', ''], ['Liste', '- ', ''], ['Lien', '[', '](https://)'], [<?= json_encode(t('public_site.markdown_table'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>, '| Colonne 1 | Colonne 2 |\n| --- | --- |\n| Valeur 1 | Valeur 2 |\n', '']];
actions.forEach(([label, before, after]) => {
const button = document.createElement('button');
button.type = 'button';