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:
@@ -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 (`&`, ` `…) + 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é
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -1 +1 @@
|
|||||||
1.6.20
|
1.6.21
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user