Étendre le Markdown du site public
This commit is contained in:
parent
21ee1bec36
commit
56a2fddc83
12 changed files with 179 additions and 52 deletions
10
CHANGELOG.md
10
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/).
|
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
|
## [1.0.0-rc.34] — 2026-10-12
|
||||||
|
|
||||||
### Pages libres et navigation du site public
|
### Pages libres et navigation du site public
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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é
|
## `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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,7 @@ L’option `--apply` ne doit être utilisée qu’après lecture des rapports. L
|
||||||
|
|
||||||
## État du projet
|
## É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).
|
Le contrôle de stabilisation est disponible dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md).
|
||||||
|
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
1.0.0-rc.34
|
1.0.0-rc.35
|
||||||
|
|
|
||||||
|
|
@ -2,77 +2,143 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/** Lightweight Markdown renderer. Raw HTML is escaped and images stay local. */
|
||||||
final class PublicContentFormatter
|
final class PublicContentFormatter
|
||||||
{
|
{
|
||||||
public static function render(string $text): string
|
public static function render(string $text): string
|
||||||
{
|
{
|
||||||
$lines = preg_split('/\R/u', trim($text)) ?: [];
|
$lines = preg_split('/\R/u', trim($text)) ?: [];
|
||||||
$html = [];
|
$html = [];
|
||||||
$listOpen = false;
|
$count = count($lines);
|
||||||
|
for ($i = 0; $i < $count;) {
|
||||||
|
$line = rtrim($lines[$i]);
|
||||||
|
if (trim($line) === '') { $i++; continue; }
|
||||||
|
|
||||||
foreach ($lines as $line) {
|
if (preg_match('/^\s*```\s*([A-Za-z0-9_-]*)\s*$/u', $line, $fence)) {
|
||||||
$line = trim($line);
|
$language = strtolower($fence[1]);
|
||||||
if ($line === '') {
|
$code = [];
|
||||||
if ($listOpen) {
|
for ($i++; $i < $count && !preg_match('/^\s*```\s*$/u', $lines[$i]); $i++) $code[] = $lines[$i];
|
||||||
$html[] = '</ul>';
|
if ($i < $count) $i++;
|
||||||
$listOpen = false;
|
$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;
|
$table .= '</tr></thead><tbody>';
|
||||||
}
|
foreach ($rows as $row) {
|
||||||
if (preg_match('/^[-*]\s+(.+)$/u', $line, $match)) {
|
$table .= '<tr>';
|
||||||
if (!$listOpen) {
|
foreach ($headers as $column => $_) {
|
||||||
$html[] = '<ul>';
|
$align = $alignments[$column] ?? '';
|
||||||
$listOpen = true;
|
$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;
|
continue;
|
||||||
}
|
}
|
||||||
if ($listOpen) {
|
|
||||||
$html[] = '</ul>';
|
if (preg_match('/^\s*([-*_])(?:\s*\1){2,}\s*$/u', $line)) { $html[] = '<hr>'; $i++; continue; }
|
||||||
$listOpen = false;
|
if (preg_match('/^(#{1,6})\s+(.+?)\s*#*$/u', trim($line), $heading)) {
|
||||||
}
|
$level = min(6, strlen($heading[1]) + 1);
|
||||||
if (preg_match('/^(#{1,3})\s+(.+)$/u', $line, $match)) {
|
$html[] = '<h' . $level . '>' . self::inline($heading[2]) . '</h' . $level . '>';
|
||||||
$level = strlen($match[1]) + 1;
|
$i++;
|
||||||
$html[] = '<h' . $level . '>' . self::inline($match[2]) . '</h' . $level . '>';
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$html[] = '<p>' . self::inline($line) . '</p>';
|
if (preg_match('/^\s*>\s?(.*)$/u', $line)) {
|
||||||
}
|
$quote = [];
|
||||||
if ($listOpen) {
|
while ($i < $count && preg_match('/^\s*>\s?(.*)$/u', $lines[$i], $match)) { $quote[] = $match[1]; $i++; }
|
||||||
$html[] = '</ul>';
|
$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);
|
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
|
private static function inline(string $text): string
|
||||||
{
|
{
|
||||||
$images = [];
|
$tokens = [];
|
||||||
$text = preg_replace_callback('/!\[([^\]]*)\]\((\/media\/site\/[A-Za-z0-9._%-]+)\)/u', static function (array $match) use (&$images): string {
|
$reserve = static function (string $html) use (&$tokens): string {
|
||||||
$token = "\x1B" . count($images) . "\x1B";
|
$token = "\x1A" . count($tokens) . "\x1A";
|
||||||
$images[] = '<img class="public-content-image" src="' .
|
$tokens[] = $html;
|
||||||
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>';
|
|
||||||
return $token;
|
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) ?? $text;
|
||||||
$text = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
$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', '<strong>$1</strong>', $text) ?? $text;
|
||||||
$text = preg_replace('/(?<!\*)\*([^*]+)\*(?!\*)/u', '<em>$1</em>', $text) ?? $text;
|
$text = preg_replace('/(?<!\*)\*([^*]+)\*(?!\*)/u', '<em>$1</em>', $text) ?? $text;
|
||||||
foreach ($links as $index => $link) {
|
$text = preg_replace('/(?<!_)_([^_]+)_(?!_)/u', '<em>$1</em>', $text) ?? $text;
|
||||||
$text = str_replace("\x1A" . $index . "\x1A", $link, $text);
|
foreach ($tokens as $index => $html) $text = str_replace("\x1A" . $index . "\x1A", $html, $text);
|
||||||
}
|
|
||||||
foreach ($images as $index => $image) {
|
|
||||||
$text = str_replace("\x1B" . $index . "\x1B", $image, $text);
|
|
||||||
}
|
|
||||||
return $text;
|
return $text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,10 @@
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<section class="card h-100">
|
<section class="card h-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<span class="badge bg-blue-lt mb-2">1.0.0-rc.34</span>
|
<span class="badge bg-blue-lt mb-2">1.0.0-rc.35</span>
|
||||||
<h3>Un site public qui s’adapte au refuge</h3>
|
<h3>Des pages publiques riches et lisibles</h3>
|
||||||
<p class="text-muted">Pages personnalisées, contenus longs, panneaux repliables et ordre du menu
|
<p class="text-muted">Les pages personnalisées accueillent maintenant tableaux responsives, listes,
|
||||||
réorganisable rendent chaque site aussi simple ou aussi complet que nécessaire.</p>
|
citations et code tout en conservant un rendu strictement sécurisé.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,40 @@ if (!function_exists('public_age')) {
|
||||||
border-radius: var(--radius)
|
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 {
|
.section {
|
||||||
padding: 52px 0
|
padding: 52px 0
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,7 @@ const enhancePublicEditor = (textarea) => {
|
||||||
textarea.addEventListener('focus', () => { activePublicEditor = textarea; });
|
textarea.addEventListener('focus', () => { activePublicEditor = textarea; });
|
||||||
const toolbar = document.createElement('div');
|
const toolbar = document.createElement('div');
|
||||||
toolbar.className = 'btn-list mb-2';
|
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]) => {
|
actions.forEach(([label, before, after]) => {
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ Le menu présente directement les espèces publiées, puis les pages de texte, d
|
||||||
|
|
||||||
Les zones de texte acceptent les contenus longs et grandissent avec leur contenu. Le pied de page utilise l’adresse complète, le RNA et le SIRET configurés. La page Contact affiche une carte positionnée sur l’adresse de l’association ; la bibliothèque cartographique est locale mais les tuiles OpenStreetMap nécessitent Internet.
|
Les zones de texte acceptent les contenus longs et grandissent avec leur contenu. Le pied de page utilise l’adresse complète, le RNA et le SIRET configurés. La page Contact affiche une carte positionnée sur l’adresse de l’association ; la bibliothèque cartographique est locale mais les tuiles OpenStreetMap nécessitent Internet.
|
||||||
|
|
||||||
|
Le contenu accepte les principaux éléments Markdown utiles à une page publique : titres, paragraphes, gras, italique, texte barré, liens HTTPS ou internes, images de la médiathèque, listes simples, numérotées ou à cocher, citations, séparateurs, code et tableaux avec alignement. Le bouton **Tableau** insère une structure prête à compléter. Les tableaux deviennent défilables horizontalement sur les petits écrans. Le HTML brut reste volontairement neutralisé pour protéger le site.
|
||||||
|
|
||||||
Le téléphone et le courriel bénéficient d’une obfuscation CSS légère contre les robots simples. Cela ne remplace ni une politique antispam ni une adresse dédiée au public.
|
Le téléphone et le courriel bénéficient d’une obfuscation CSS légère contre les robots simples. Cela ne remplace ni une politique antispam ni une adresse dédiée au public.
|
||||||
|
|
||||||
## Mentions légales et confidentialité
|
## Mentions légales et confidentialité
|
||||||
|
|
|
||||||
|
|
@ -276,4 +276,5 @@ return [
|
||||||
'public_site.move_down'=>'Move page down',
|
'public_site.move_down'=>'Move page down',
|
||||||
'public_site.page_invalid'=>'A custom page title or address is invalid or reserved.',
|
'public_site.page_invalid'=>'A custom page title or address is invalid or reserved.',
|
||||||
'public_site.page_slug_duplicate'=>'Two custom pages cannot use the same address.',
|
'public_site.page_slug_duplicate'=>'Two custom pages cannot use the same address.',
|
||||||
|
'public_site.markdown_table'=>'Table',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -276,4 +276,5 @@ return [
|
||||||
'public_site.move_down'=>'Descendre la page',
|
'public_site.move_down'=>'Descendre la page',
|
||||||
'public_site.page_invalid'=>'Le titre ou l’adresse d’une page personnalisée est invalide ou réservé.',
|
'public_site.page_invalid'=>'Le titre ou l’adresse d’une page personnalisée est invalide ou réservé.',
|
||||||
'public_site.page_slug_duplicate'=>'Deux pages personnalisées ne peuvent pas utiliser la même adresse.',
|
'public_site.page_slug_duplicate'=>'Deux pages personnalisées ne peuvent pas utiliser la même adresse.',
|
||||||
|
'public_site.markdown_table'=>'Tableau',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,15 @@ try {
|
||||||
$assert(str_contains($image, '<img class="public-content-image"'));
|
$assert(str_contains($image, '<img class="public-content-image"'));
|
||||||
$externalImage = PublicContentFormatter::render('');
|
$externalImage = PublicContentFormatter::render('');
|
||||||
$assert(!str_contains($externalImage, '<img'), 'Une image externe a été acceptée');
|
$assert(!str_contains($externalImage, '<img'), 'Une image externe a été acceptée');
|
||||||
|
$markdown = PublicContentFormatter::render("| Nom | Prix |\n| :--- | ---: |\n| **Vaccin** | 27 € |\n\n1. Premier\n2. Second\n\n> Important\n\n`<script>`\n\n~~ancien~~");
|
||||||
|
$assert(str_contains($markdown, '<table>'), 'Les tableaux Markdown ne sont pas rendus');
|
||||||
|
$assert(str_contains($markdown, 'class="text-right"'), 'L’alignement des tableaux est perdu');
|
||||||
|
$assert(str_contains($markdown, '<ol>'), 'Les listes numérotées ne sont pas rendues');
|
||||||
|
$assert(str_contains($markdown, '<blockquote>'), 'Les citations ne sont pas rendues');
|
||||||
|
$assert(str_contains($markdown, '<code><script></code>'), 'Le code en ligne n’est pas protégé');
|
||||||
|
$assert(str_contains($markdown, '<del>ancien</del>'), 'Le texte barré n’est pas rendu');
|
||||||
|
$fenced = PublicContentFormatter::render("```html\n<script>alert(1)</script>\n```");
|
||||||
|
$assert(!str_contains($fenced, '<script>'), 'Le bloc de code permet une injection HTML');
|
||||||
});
|
});
|
||||||
$test('Le menu public affiche uniquement les réseaux sociaux configurés', function () use ($assert): void {
|
$test('Le menu public affiche uniquement les réseaux sociaux configurés', function () use ($assert): void {
|
||||||
$layout = file_get_contents(__DIR__ . '/../app/Views/public_site_layout.php');
|
$layout = file_get_contents(__DIR__ . '/../app/Views/public_site_layout.php');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue