perf & ux : cache getAll, fingerprint assets, Last-Modified, 404 log, row-click bulk (v1.6.19)

- getAll() : cache fichier articles_list.json, invalidé à chaque écriture (#16)
- layout.php : fingerprinting ?v=<hash> sur CSS/JS pour invalidation navigateur (#18)
- case 'view' : Last-Modified + 304 Not Modified pour les articles publiés (#18)
- case 'not_found' : logging JSON des 404 dans _logs/not_found.jsonl (#52)
- case 'view' : echo nu → templates/404.php pour brouillons/privés (#52)
- admin.js : clic sur ligne tableau → toggle bulk-check (#86)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 10:44:08 +02:00
parent 11399a54a6
commit 5ce91da06a
6 changed files with 113 additions and 9 deletions
+14
View File
@@ -5,6 +5,20 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
--- ---
## [1.6.19] - 2026-05-16
### Ajouté
- `admin/articles` : clic sur la ligne entière pour cocher/décocher la case de sélection bulk (#86)
- Cache HTTP `Last-Modified` + réponse `304 Not Modified` pour les articles publiés (#18)
- Fingerprinting des assets CSS/JS dans `layout.php` (`?v=<hash>`) pour invalidation automatique du cache navigateur (#18)
- Cache fichier `_cache/articles_list.json` pour `getAll()` — invalidé à chaque écriture, élimine le scan complet par requête (#16)
- Logging des 404 dans `DATA_PATH/_logs/not_found.json` (url, referer, user-agent, date) (#52)
### Corrigé
- `case 'view'` : les accès refusés (brouillon, avant-première, catégorie privée) utilisent désormais `templates/404.php` au lieu d'un `echo` nu (#52)
---
## [1.6.18] - 2026-05-16 ## [1.6.18] - 2026-05-16
### Ajouté ### Ajouté
+17
View File
@@ -19,6 +19,23 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
} }
// Clic sur la ligne entière pour cocher/décocher la case de sélection
document.querySelectorAll('table tbody tr').forEach(function (tr) {
var cb = tr.querySelector('.bulk-check');
if (!cb) { return; }
tr.style.cursor = 'pointer';
tr.addEventListener('click', function (e) {
if (e.target.closest('a, button, input, label')) { return; }
cb.checked = !cb.checked;
if (checkAll) {
var total = document.querySelectorAll('.bulk-check').length;
var checked = document.querySelectorAll('.bulk-check:checked').length;
checkAll.checked = total > 0 && checked === total;
checkAll.indeterminate = checked > 0 && checked < total;
}
});
});
// Indicateurs de traitement formulaire SMTP (config + tester connexion) // Indicateurs de traitement formulaire SMTP (config + tester connexion)
var smtpForm = document.getElementById('smtp-config-form'); var smtpForm = document.getElementById('smtp-config-form');
if (smtpForm) { if (smtpForm) {
+46 -3
View File
@@ -50,6 +50,25 @@ $metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' :
unset($_noindexActions); unset($_noindexActions);
// ─── Recherche de l'article le plus proche et redirection 301 ──────────────── // ─── Recherche de l'article le plus proche et redirection 301 ────────────────
function log404(string $url): void
{
if (!defined('DATA_PATH')) {
return;
}
$logDir = DATA_PATH . '/_logs';
$logFile = $logDir . '/not_found.jsonl';
if (!is_dir($logDir)) {
@mkdir($logDir, 0755, true);
}
$entry = json_encode([
'ts' => date('Y-m-d H:i:s'),
'url' => $url,
'ref' => $_SERVER['HTTP_REFERER'] ?? '',
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
@file_put_contents($logFile, $entry, FILE_APPEND | LOCK_EX);
}
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(
@@ -679,7 +698,7 @@ switch ($action) {
if (!$article['published']) { if (!$article['published']) {
if (!canDoOnArticle('view_drafts', $article)) { if (!canDoOnArticle('view_drafts', $article)) {
http_response_code(404); http_response_code(404);
echo 'Article introuvable.'; include BASE_PATH . '/templates/404.php';
exit; exit;
} }
} }
@@ -688,7 +707,7 @@ switch ($action) {
if ($article['published'] && strtotime((string)($article['published_at'] ?? '')) > time()) { if ($article['published'] && strtotime((string)($article['published_at'] ?? '')) > time()) {
if (!hasCapability('view_previews')) { if (!hasCapability('view_previews')) {
http_response_code(404); http_response_code(404);
echo 'Article introuvable.'; include BASE_PATH . '/templates/404.php';
exit; exit;
} }
} }
@@ -700,10 +719,33 @@ switch ($action) {
$isPrivateCat = $articleCat !== '' && in_array($articleCat, $privateCats, true); $isPrivateCat = $articleCat !== '' && in_array($articleCat, $privateCats, true);
if ($isPrivateCat && !isLoggedIn()) { if ($isPrivateCat && !isLoggedIn()) {
http_response_code(404); http_response_code(404);
echo 'Article introuvable.'; include BASE_PATH . '/templates/404.php';
exit; exit;
} }
// Cache HTTP : Last-Modified + 304 pour les articles publiés
if ($article['published']) {
$_uuid = $article['uuid'] ?? '';
$_mdFile = DATA_PATH . '/' . $_uuid . '/index.md';
$_mfFile = DATA_PATH . '/' . $_uuid . '/meta.json';
$_lm = max(
is_file($_mdFile) ? (int)filemtime($_mdFile) : 0,
is_file($_mfFile) ? (int)filemtime($_mfFile) : 0
);
if ($_lm > 0) {
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $_lm) . ' GMT');
header('Cache-Control: public, max-age=60');
$ifModSince = isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])
? strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE'])
: false;
if ($ifModSince !== false && $_lm <= $ifModSince) {
http_response_code(304);
exit;
}
}
unset($_uuid, $_mdFile, $_mfFile, $_lm, $ifModSince);
}
$files = $articles->getFiles($article['uuid']); $files = $articles->getFiles($article['uuid']);
// Résout les chemins de fichiers relatifs dans le contenu // Résout les chemins de fichiers relatifs dans le contenu
@@ -3473,6 +3515,7 @@ switch ($action) {
(string)(parse_url($_SERVER['REDIRECT_URL'] ?? $_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? ''), (string)(parse_url($_SERVER['REDIRECT_URL'] ?? $_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? ''),
'/' '/'
); );
log404('/' . $notFoundPath);
$q = slugToSearchQuery($notFoundPath); $q = slugToSearchQuery($notFoundPath);
header('Location: /search' . ($q !== '' ? '?q=' . urlencode($q) : ''), true, 302); header('Location: /search' . ($q !== '' ? '?q=' . urlencode($q) : ''), true, 302);
exit; exit;
+1 -1
View File
@@ -1 +1 @@
1.6.18 1.6.19
+22 -1
View File
@@ -30,6 +30,14 @@ class ArticleManager
private function loadAll(): array private function loadAll(): array
{ {
$cachePath = $this->allListCachePath();
if (file_exists($cachePath)) {
$cached = json_decode((string)file_get_contents($cachePath), true);
if (is_array($cached) && $cached !== []) {
return $cached;
}
}
$articles = []; $articles = [];
if (!is_dir($this->dataDir)) { if (!is_dir($this->dataDir)) {
return $articles; return $articles;
@@ -66,6 +74,12 @@ class ArticleManager
} }
} }
$cacheDir = dirname($cachePath);
if (!is_dir($cacheDir)) {
@mkdir($cacheDir, 0755, true);
}
@file_put_contents($cachePath, json_encode($articles, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return $articles; return $articles;
} }
@@ -849,6 +863,7 @@ class ArticleManager
$this->allCache = null; $this->allCache = null;
@unlink($this->articleCachePath($uuid)); @unlink($this->articleCachePath($uuid));
@unlink($this->slugIndexPath()); @unlink($this->slugIndexPath());
@unlink($this->allListCachePath());
$this->removeDir($dir); $this->removeDir($dir);
} }
if (is_dir($dir)) { if (is_dir($dir)) {
@@ -879,6 +894,11 @@ class ArticleManager
return $this->dataDir . '/_cache/slug_index.json'; return $this->dataDir . '/_cache/slug_index.json';
} }
private function allListCachePath(): string
{
return $this->dataDir . '/_cache/articles_list.json';
}
private function buildSlugIndex(): void private function buildSlugIndex(): void
{ {
$cacheDir = $this->dataDir . '/_cache'; $cacheDir = $this->dataDir . '/_cache';
@@ -1343,9 +1363,10 @@ class ArticleManager
$this->searchIndexCache = null; $this->searchIndexCache = null;
$uuid = $meta['uuid'] ?? basename($dir); $uuid = $meta['uuid'] ?? basename($dir);
// Invalider le cache article et le slug index // Invalider les caches article, liste et slug index
@unlink($this->articleCachePath($uuid)); @unlink($this->articleCachePath($uuid));
@unlink($this->slugIndexPath()); @unlink($this->slugIndexPath());
@unlink($this->allListCachePath());
file_put_contents( file_put_contents(
$dir . '/meta.json', $dir . '/meta.json',
+13 -4
View File
@@ -46,11 +46,20 @@
<!-- CSS --> <!-- CSS -->
<link href="/assets/css/bootstrap.min.css" rel="stylesheet"> <link href="/assets/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/assets/css/style.css"> <?php
$_pub = BASE_PATH . '/public/assets/';
if (!function_exists('_av')) {
function _av(string $base, string $rel): string {
$f = $base . $rel;
return '/assets/' . $rel . (is_file($f) ? '?v=' . substr(md5_file($f), 0, 8) : '');
}
}
?>
<link rel="stylesheet" href="<?= _av($_pub, 'css/style.css') ?>">
</head> </head>
<body<?php if (!empty($bodyClass ?? '')): ?> class="<?= htmlspecialchars($bodyClass) ?>"<?php endif; ?>> <body<?php if (!empty($bodyClass ?? '')): ?> class="<?= htmlspecialchars($bodyClass) ?>"<?php endif; ?>>
<script src="/assets/js/density-fouc.js"></script> <script src="<?= _av($_pub, 'js/density-fouc.js') ?>"></script>
<header> <header>
<nav class="navbar navbar-expand-lg navbar-dark mb-0" role="navigation" aria-label="Navigation principale"> <nav class="navbar navbar-expand-lg navbar-dark mb-0" role="navigation" aria-label="Navigation principale">
@@ -150,9 +159,9 @@ $_layoutCurrentCat = trim($_GET['cat'] ?? '');
<!-- JS --> <!-- JS -->
<script src="/assets/js/bootstrap.bundle.min.js"></script> <script src="/assets/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/app.js"></script> <script src="<?= _av($_pub, 'js/app.js') ?>"></script>
<?php if (isset($reactionStats)): ?> <?php if (isset($reactionStats)): ?>
<script src="/assets/js/reactions.js"></script> <script src="<?= _av($_pub, 'js/reactions.js') ?>"></script>
<?php endif; ?> <?php endif; ?>
</body> </body>