Étendre le Markdown du site public
This commit is contained in:
parent
21ee1bec36
commit
56a2fddc83
12 changed files with 179 additions and 52 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue