From 56a2fddc834f04ebc24d12d633c3038eda8f63a9 Mon Sep 17 00:00:00 2001 From: oversu <95a7002b-481b-4ef3-b578-f9c421550aa4@localhost> Date: Mon, 12 Oct 2026 10:59:11 +0200 Subject: [PATCH] =?UTF-8?q?=C3=89tendre=20le=20Markdown=20du=20site=20publ?= =?UTF-8?q?ic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++ HISTORIQUE.md | 4 + README.md | 2 +- VERSION | 2 +- app/Services/PublicContentFormatter.php | 160 +++++++++++++++++------- app/Views/evolutions.php | 8 +- app/Views/public_site_layout.php | 34 +++++ app/Views/settings_public_site.php | 2 +- docs/08-documents-et-site-public.md | 2 + resources/lang/en.php | 1 + resources/lang/fr.php | 1 + tests/run.php | 9 ++ 12 files changed, 181 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0619f7d..f601345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ Toutes les modifications importantes du projet sont consignées ici. Le format s’inspire de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/). +## [1.0.0-rc.35] — 2026-10-12 + +### Markdown enrichi sur le site public + +- ajout des tableaux Markdown, avec alignement des colonnes et défilement adapté aux mobiles ; +- prise en charge des listes numérotées et à cocher, citations, séparateurs, blocs de code, code en ligne et texte barré ; +- ajout d’un bouton insérant un modèle de tableau dans l’éditeur ; +- maintien de l’échappement du HTML brut, des protocoles de liens autorisés et des images limitées à la médiathèque publique ; +- tests de non-régression couvrant le rendu enrichi et les tentatives d’injection. + ## [1.0.0-rc.34] — 2026-10-12 ### Pages libres et navigation du site public diff --git a/HISTORIQUE.md b/HISTORIQUE.md index 222bee8..5b92140 100644 --- a/HISTORIQUE.md +++ b/HISTORIQUE.md @@ -10,6 +10,10 @@ L’import depuis Animal Shelter Manager a été conçu comme un parcours pruden Avant la version 1.0, le projet a fait l’objet d’un nettoyage structurel et d’un découpage de ses fichiers les plus volumineux. La recherche universelle, les salles configurables, les traductions française et anglaise et une suite de contrôles isolés ont complété cette phase de stabilisation. +## `1.0.0-rc.35` — Des contenus publics réellement structurés + +Le moteur Markdown léger apprend à présenter tableaux, listes numérotées ou à cocher, citations, code et séparateurs sans introduire de dépendance externe. Les tableaux restent lisibles sur smartphone et le HTML non fiable demeure neutralisé. + ## `1.0.0-rc.34` — Un site public librement structuré Les associations peuvent désormais créer autant de pages éditoriales que nécessaire, les publier dans la navigation et choisir leur ordre par glisser-déposer. L’écran d’édition adopte des panneaux repliables et les contenus longs ne sont plus tronqués par Globinours. diff --git a/README.md b/README.md index 58ed471..64d813f 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ L’option `--apply` ne doit être utilisée qu’après lecture des rapports. L ## État du projet -La candidate actuelle est `1.0.0-rc.34`. Le site public accepte désormais des pages personnalisées, réordonnables et sans troncature applicative du contenu. La branche 1.0 reste en stabilisation avant `1.0.0`. +La candidate actuelle est `1.0.0-rc.35`. Le site public accepte des pages personnalisées réordonnables et un Markdown enrichi, notamment les tableaux responsives. La branche 1.0 reste en stabilisation avant `1.0.0`. Le contrôle de stabilisation est disponible dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md). diff --git a/VERSION b/VERSION index 0f0d470..63f99b9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0-rc.34 +1.0.0-rc.35 diff --git a/app/Services/PublicContentFormatter.php b/app/Services/PublicContentFormatter.php index ae4bb1a..0736b4a 100644 --- a/app/Services/PublicContentFormatter.php +++ b/app/Services/PublicContentFormatter.php @@ -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[] = ''; - $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[] = '
' . 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 = '| ' . self::inline($header) . ' | '; } - continue; - } - if (preg_match('/^[-*]\s+(.+)$/u', $line, $match)) { - if (!$listOpen) { - $html[] = '
|---|
| ' . self::inline($row[$column] ?? '') . ' | '; + } + $table .= '
' . self::inline($line) . '
'; - } - if ($listOpen) { - $html[] = ''; + 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 .= '
' . 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 { - $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[] = '' . 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('Pages personnalisées, contenus longs, panneaux repliables et ordre du menu - réorganisable rendent chaque site aussi simple ou aussi complet que nécessaire.
+ 1.0.0-rc.35 +Les pages personnalisées accueillent maintenant tableaux responsives, listes, + citations et code tout en conservant un rendu strictement sécurisé.