fix & feat : SEO desc, feed cover, livres slug auto + filtre (v1.6.21)

- buildAutoSeoDesc() : entités HTML décodées + titre supprimé en tête (#91)
- post_confirm.js : guard null sur #confirm-slug absent (#91)
- feed.php : <media:thumbnail> avec image de couverture RSS (#90)
- admin livres : slug auto depuis le titre + filtre articles (#89)
- BookManager::sanitizeSlug() passé public

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 11:05:01 +02:00
parent 3b22be94e8
commit ca6cfa4ebf
8 changed files with 92 additions and 27 deletions
+13
View File
@@ -5,6 +5,19 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
--- ---
## [1.6.21] - 2026-05-16
### Ajouté
- Feed RSS : balise `<media:thumbnail>` avec l'image de couverture de l'article (namespace `media:`) (#90)
- Admin livres : slug généré automatiquement depuis le titre à la création (#89)
- Admin livres : champ de filtre texte en temps réel sur le sélecteur « Ajouter une page » (#89)
### Corrigé
- `autoSeoDesc` : décodage des entités HTML (`&amp;`, `&nbsp;`…) + suppression du titre en tête de description (#91)
- `post_confirm.js` : guard null sur `#confirm-slug` absent (étape 5 du wizard) — plus d'erreur JS (#91)
---
## [1.6.20] - 2026-05-16 ## [1.6.20] - 2026-05-16
### Ajouté ### Ajouté
+32
View File
@@ -85,5 +85,37 @@ document.addEventListener('DOMContentLoaded', function () {
if (lines.indexOf(slug) === -1) { lines.push(slug); ta.value = lines.join('\n'); } if (lines.indexOf(slug) === -1) { lines.push(slug); ta.value = lines.join('\n'); }
bookArticleSel.value = ''; bookArticleSel.value = '';
}); });
// Filtre texte en temps réel pour le sélecteur d'articles
var bookFilter = document.getElementById('book-article-filter');
if (bookFilter) {
var bookOptions = Array.from(bookArticleSel.options);
bookFilter.addEventListener('input', function () {
var q = bookFilter.value.trim().toLowerCase();
bookArticleSel.innerHTML = '';
bookOptions.forEach(function (opt) {
if (opt.value === '' || q === '' || opt.textContent.toLowerCase().includes(q)) {
bookArticleSel.appendChild(opt.cloneNode(true));
}
});
});
}
}
// Slug auto pour la création d'un livre
var newBookTitle = document.getElementById('new-book-title');
var newBookSlugPreview = document.getElementById('new-book-slug-preview');
var newBookSlugHidden = document.getElementById('new-book-slug-hidden');
if (newBookTitle && newBookSlugPreview && newBookSlugHidden) {
function toBookSlug(s) {
var map = { 'à':'a','â':'a','ä':'a','é':'e','è':'e','ê':'e','ë':'e','î':'i','ï':'i','ô':'o','ö':'o','ù':'u','û':'u','ü':'u','ç':'c','æ':'ae','œ':'oe' };
s = s.toLowerCase().replace(/[àâäéèêëîïôöùûüçæœ]/g, function (c) { return map[c] || c; });
return s.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
newBookTitle.addEventListener('input', function () {
var slug = toBookSlug(newBookTitle.value);
newBookSlugPreview.value = slug;
newBookSlugHidden.value = slug;
});
} }
}); });
+6 -4
View File
@@ -24,10 +24,12 @@ document.addEventListener('DOMContentLoaded', function () {
function updatePreview() { function updatePreview() {
var seoTitle = document.getElementById('seo_title').value.trim(); var seoTitle = document.getElementById('seo_title').value.trim();
var seoDesc = document.getElementById('seo_description').value.trim(); var seoDesc = document.getElementById('seo_description').value.trim();
var slug = document.getElementById('confirm-slug').value.trim(); var slugEl = document.getElementById('confirm-slug');
document.getElementById('preview-title').textContent = seoTitle || defaultTitle; document.getElementById('preview-title').textContent = seoTitle || defaultTitle;
document.getElementById('preview-desc').textContent = seoDesc || defaultDesc; document.getElementById('preview-desc').textContent = seoDesc || defaultDesc;
document.getElementById('preview-url').textContent = baseUrl + slug; if (slugEl) {
document.getElementById('preview-url').textContent = baseUrl + slugEl.value.trim();
}
} }
['seo_title', 'seo_description', 'confirm-slug'].forEach(function (id) { ['seo_title', 'seo_description', 'confirm-slug'].forEach(function (id) {
@@ -39,7 +41,7 @@ document.addEventListener('DOMContentLoaded', function () {
var slugDisplay = document.getElementById('slug-display'); var slugDisplay = document.getElementById('slug-display');
var btnSuggest = document.getElementById('slug-btn-suggest'); var btnSuggest = document.getElementById('slug-btn-suggest');
if (btnSuggest) { if (btnSuggest && slugInput && slugDisplay) {
btnSuggest.addEventListener('click', function () { btnSuggest.addEventListener('click', function () {
var val = btnSuggest.dataset.slugSuggest; var val = btnSuggest.dataset.slugSuggest;
slugInput.value = val; slugInput.value = val;
@@ -49,7 +51,7 @@ document.addEventListener('DOMContentLoaded', function () {
} }
var btnKeep = document.getElementById('slug-btn-keep'); var btnKeep = document.getElementById('slug-btn-keep');
if (btnKeep) { if (btnKeep && slugInput && slugDisplay) {
btnKeep.addEventListener('click', function () { btnKeep.addEventListener('click', function () {
var val = btnKeep.dataset.slugKeep; var val = btnKeep.dataset.slugKeep;
slugInput.value = val; slugInput.value = val;
+8
View File
@@ -80,6 +80,7 @@ echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
<rss version="2.0" <rss version="2.0"
xmlns:atom="http://www.w3.org/2005/Atom" xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:media="http://search.yahoo.com/mrss/"
xmlns:fh="http://purl.org/syndication/history/1.0"> xmlns:fh="http://purl.org/syndication/history/1.0">
<channel> <channel>
<title><?= htmlspecialchars($channelTitle) ?></title> <title><?= htmlspecialchars($channelTitle) ?></title>
@@ -111,6 +112,10 @@ echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
$mdPath = DATA_PATH . '/' . ($article['uuid'] ?? '') . '/index.md'; $mdPath = DATA_PATH . '/' . ($article['uuid'] ?? '') . '/index.md';
$rawMd = file_exists($mdPath) ? (string)file_get_contents($mdPath) : ''; $rawMd = file_exists($mdPath) ? (string)file_get_contents($mdPath) : '';
$fullHtml = $rawMd !== '' ? $Parsedown->text($rawMd) : ''; $fullHtml = $rawMd !== '' ? $Parsedown->text($rawMd) : '';
$imgUrl = trim($article['og_image'] ?? '');
if ($imgUrl === '' && ($article['cover'] ?? '') !== '') {
$imgUrl = $base . '/file?uuid=' . rawurlencode($article['uuid']) . '&name=' . rawurlencode($article['cover']);
}
?> ?>
<item> <item>
<title><?= $title ?></title> <title><?= $title ?></title>
@@ -119,6 +124,9 @@ echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
<?php if ($fullHtml !== ''): ?> <?php if ($fullHtml !== ''): ?>
<content:encoded><![CDATA[<?= $fullHtml ?>]]></content:encoded> <content:encoded><![CDATA[<?= $fullHtml ?>]]></content:encoded>
<?php endif; ?> <?php endif; ?>
<?php if ($imgUrl !== ''): ?>
<media:thumbnail url="<?= htmlspecialchars($imgUrl, ENT_XML1) ?>"/>
<?php endif; ?>
<pubDate><?= htmlspecialchars($pubDate) ?></pubDate> <pubDate><?= htmlspecialchars($pubDate) ?></pubDate>
<guid isPermaLink="true"><?= $guid ?></guid> <guid isPermaLink="true"><?= $guid ?></guid>
</item> </item>
+19 -12
View File
@@ -69,6 +69,19 @@ function log404(string $url): void
@file_put_contents($logFile, $entry, FILE_APPEND | LOCK_EX); @file_put_contents($logFile, $entry, FILE_APPEND | LOCK_EX);
} }
function buildAutoSeoDesc(string $content, string $title = ''): string
{
require_once BASE_PATH . '/src/Parsedown.php';
$_pd = new Parsedown();
$_plain = trim((string)preg_replace('/\s+/', ' ',
html_entity_decode(strip_tags($_pd->text($content)), ENT_QUOTES | ENT_HTML5, 'UTF-8')
));
if ($title !== '' && stripos($_plain, $title) === 0) {
$_plain = ltrim(substr($_plain, strlen($title)));
}
return mb_strimwidth($_plain, 0, 155, '…');
}
function slugToSearchQuery(string $rawPath): string function slugToSearchQuery(string $rawPath): string
{ {
return trim((string)preg_replace('/\s{2,}/', ' ', (string)preg_replace( return trim((string)preg_replace('/\s{2,}/', ' ', (string)preg_replace(
@@ -653,10 +666,7 @@ switch ($action) {
header('Location: /new'); header('Location: /new');
exit; exit;
} }
require_once BASE_PATH . '/src/Parsedown.php'; $autoSeoDesc = buildAutoSeoDesc((string)($draft['content'] ?? ''), trim($draft['title'] ?? ''));
$_pd = new Parsedown();
$autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draft['content'] ?? ''))))), 0, 155, '…');
unset($_pd);
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$seoTitle = trim($_POST['seo_title'] ?? ''); $seoTitle = trim($_POST['seo_title'] ?? '');
$seoDesc = trim($_POST['seo_description'] ?? '') ?: $autoSeoDesc; $seoDesc = trim($_POST['seo_description'] ?? '') ?: $autoSeoDesc;
@@ -1021,10 +1031,7 @@ switch ($action) {
header('Location: /edit/' . rawurlencode($uuid) . '/6'); header('Location: /edit/' . rawurlencode($uuid) . '/6');
exit; exit;
} }
require_once BASE_PATH . '/src/Parsedown.php'; $autoSeoDesc = buildAutoSeoDesc((string)($draft['content'] ?? ''), trim($draft['title'] ?? ''));
$_pd = new Parsedown();
$autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draft['content'] ?? ''))))), 0, 155, '…');
unset($_pd);
$title = $draft['title']; $title = $draft['title'];
$seoTitle = $draft['seo_title'] ?? ''; $seoTitle = $draft['seo_title'] ?? '';
$seoDescription = $draft['seo_description'] ?? ''; $seoDescription = $draft['seo_description'] ?? '';
@@ -1046,10 +1053,7 @@ switch ($action) {
exit; exit;
} }
$draftData = $articles->getDraftOverlay($uuid) ?? $article; $draftData = $articles->getDraftOverlay($uuid) ?? $article;
require_once BASE_PATH . '/src/Parsedown.php'; $autoSeoDesc = buildAutoSeoDesc((string)($draftData['content'] ?? ''), trim($draftData['title'] ?? ''));
$_pd = new Parsedown();
$autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draftData['content'] ?? ''))))), 0, 155, '…');
unset($_pd);
$diffLines = lineDiff((string)($article['content'] ?? ''), (string)($draftData['content'] ?? '')); $diffLines = lineDiff((string)($article['content'] ?? ''), (string)($draftData['content'] ?? ''));
$titleChanged = ($draftData['title'] ?? '') !== ($article['title'] ?? ''); $titleChanged = ($draftData['title'] ?? '') !== ($article['title'] ?? '');
$postSlug = $draftData['slug'] ?? $article['slug']; $postSlug = $draftData['slug'] ?? $article['slug'];
@@ -3491,6 +3495,9 @@ switch ($action) {
$bTitle = trim($_POST['title'] ?? ''); $bTitle = trim($_POST['title'] ?? '');
$bDesc = trim($_POST['description'] ?? ''); $bDesc = trim($_POST['description'] ?? '');
$bArts = array_values(array_filter(array_map('trim', preg_split('/[\r\n]+/', $_POST['articles'] ?? '')))); $bArts = array_values(array_filter(array_map('trim', preg_split('/[\r\n]+/', $_POST['articles'] ?? ''))));
if ($bSlug === '' && $bTitle !== '') {
$bSlug = $books->sanitizeSlug($bTitle);
}
if ($bSlug !== '' && $bTitle !== '') { if ($bSlug !== '' && $bTitle !== '') {
$books->save(['slug' => $bSlug, 'title' => $bTitle, 'description' => $bDesc, 'articles' => $bArts]); $books->save(['slug' => $bSlug, 'title' => $bTitle, 'description' => $bDesc, 'articles' => $bArts]);
} }
+1 -1
View File
@@ -1 +1 @@
1.6.20 1.6.21
+1 -1
View File
@@ -117,7 +117,7 @@ class BookManager
return $this->booksDir . '/' . $slug . '.json'; return $this->booksDir . '/' . $slug . '.json';
} }
private function sanitizeSlug(string $slug): string public function sanitizeSlug(string $slug): string
{ {
$map = [ $map = [
'à' => 'a', 'â' => 'a', 'ä' => 'a', 'à' => 'a', 'â' => 'a', 'ä' => 'a',
+10 -7
View File
@@ -1342,6 +1342,8 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label small fw-medium">Ajouter une page existante</label> <label class="form-label small fw-medium">Ajouter une page existante</label>
<input type="text" id="book-article-filter" class="form-control form-control-sm mb-1"
placeholder="Filtrer les articles…" autocomplete="off">
<select class="form-select" id="book-article-select"> <select class="form-select" id="book-article-select">
<option value="">— Choisir un article —</option> <option value="">— Choisir un article —</option>
<?php <?php
@@ -1377,15 +1379,16 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
<?php elseif (isset($_GET['new'])): ?> <?php elseif (isset($_GET['new'])): ?>
<h5>Nouveau livre</h5> <h5>Nouveau livre</h5>
<form method="POST" action="/?action=book_save"> <form method="POST" action="/?action=book_save">
<div class="mb-3"> <input type="hidden" name="slug" id="new-book-slug-hidden">
<label class="form-label small fw-medium">Slug (identifiant URL)</label>
<input type="text" name="slug" class="form-control" required
placeholder="mon-livre" pattern="[a-z0-9][a-z0-9-]*">
<div class="form-text">Minuscules, chiffres, tirets. Exemple : <code>esp8266</code></div>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label small fw-medium">Titre</label> <label class="form-label small fw-medium">Titre</label>
<input type="text" name="title" class="form-control" required placeholder="Titre du livre"> <input type="text" name="title" id="new-book-title" class="form-control" required placeholder="Titre du livre">
</div>
<div class="mb-2">
<label class="form-label small fw-medium">Slug (généré automatiquement)</label>
<input type="text" id="new-book-slug-preview" class="form-control form-control-sm bg-light font-monospace"
readonly placeholder="slug-du-livre">
<div class="form-text">Minuscules, chiffres, tirets — basé sur le titre.</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label small fw-medium">Description (optionnelle)</label> <label class="form-label small fw-medium">Description (optionnelle)</label>