feat : magic link confirm, notif auteur, rate-limit IP, duplicate, cache MD, lazy img (v1.6.18)

- magic.php : GET=confirmation page, POST=consommation (protège vs scanners) (#27)
- verify_comment : email de notification à l'auteur de l'article (#44)
- login/index.php : rate limit par IP (MAGIC_MAX_PER_IP_HOUR=10) (#23)
- ArticleManager::duplicate() + route POST /duplicate/{uuid} + bouton ⧉ admin/articles (#7)
- post_view.php : cache JSON du rendu Markdown (invalidé sur mtime index.md) (#17)
- post_view.php : loading="lazy" sur toutes les <img> du contenu (#21)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 10:30:55 +02:00
parent 51055b7321
commit 11399a54a6
9 changed files with 201 additions and 43 deletions
+56 -27
View File
@@ -9,33 +9,62 @@ $_accentMap = [
];
$_tocItems = [];
$_tocSeen = [];
// Le titre H1 est déjà affiché par le template ; on le retire du rendu.
$_rawForRender = preg_replace('/^\s*# [^\n]*\n*/u', '', $rawContent);
$_renderedContent = preg_replace_callback(
'/<(h[23])>(.+?)<\/h[23]>/i',
function ($m) use (&$_tocItems, &$_tocSeen, $_accentMap) {
$tag = $m[1];
$inner = $m[2];
$level = (int) substr($tag, 1);
$plain = strip_tags($inner);
$slug = trim(preg_replace(
'/[^a-z0-9]+/',
'-',
mb_strtolower(strtr($plain, $_accentMap), 'UTF-8')
), '-') ?: 'section';
if (isset($_tocSeen[$slug])) {
$_tocSeen[$slug]++;
$id = $slug . '-' . $_tocSeen[$slug];
} else {
$_tocSeen[$slug] = 0;
$id = $slug;
}
$_tocItems[] = ['level' => $level, 'text' => $plain, 'id' => $id];
return "<{$tag} id=\"" . htmlspecialchars($id) . "\">{$inner}</{$tag}>";
},
$Parsedown->text($_rawForRender)
);
$_renderedContent = typographieHtml($_renderedContent ?? '');
// Cache du rendu Markdown (invalidé si index.md est plus récent)
$_mdFile = defined('DATA_PATH') ? DATA_PATH . '/' . ($article['uuid'] ?? '') . '/index.md' : '';
$_cacheFile = defined('DATA_PATH') ? DATA_PATH . '/' . ($article['uuid'] ?? '') . '/_cache/content_rendered.json' : '';
$_mdMtime = ($_mdFile !== '' && file_exists($_mdFile)) ? (int)filemtime($_mdFile) : 0;
$_renderedContent = null;
if ($_cacheFile !== '' && file_exists($_cacheFile)) {
$_tmp = json_decode((string)file_get_contents($_cacheFile), true);
if (is_array($_tmp) && isset($_tmp['ts'], $_tmp['html'], $_tmp['toc'])
&& (int)$_tmp['ts'] >= $_mdMtime && $_mdMtime > 0) {
$_renderedContent = $_tmp['html'];
$_tocItems = $_tmp['toc'];
}
}
if ($_renderedContent === null) {
// Le titre H1 est déjà affiché par le template ; on le retire du rendu.
$_rawForRender = preg_replace('/^\s*# [^\n]*\n*/u', '', $rawContent);
$_renderedContent = preg_replace_callback(
'/<(h[23])>(.+?)<\/h[23]>/i',
function ($m) use (&$_tocItems, &$_tocSeen, $_accentMap) {
$tag = $m[1];
$inner = $m[2];
$level = (int) substr($tag, 1);
$plain = strip_tags($inner);
$slug = trim(preg_replace(
'/[^a-z0-9]+/',
'-',
mb_strtolower(strtr($plain, $_accentMap), 'UTF-8')
), '-') ?: 'section';
if (isset($_tocSeen[$slug])) {
$_tocSeen[$slug]++;
$id = $slug . '-' . $_tocSeen[$slug];
} else {
$_tocSeen[$slug] = 0;
$id = $slug;
}
$_tocItems[] = ['level' => $level, 'text' => $plain, 'id' => $id];
return "<{$tag} id=\"" . htmlspecialchars($id) . "\">{$inner}</{$tag}>";
},
$Parsedown->text($_rawForRender)
);
$_renderedContent = typographieHtml($_renderedContent ?? '');
// Lazy loading sur toutes les images du contenu
$_renderedContent = preg_replace('/<img\b([^>]*)>/i', '<img$1 loading="lazy">', $_renderedContent ?? '') ?? $_renderedContent;
// Écriture du cache
if ($_cacheFile !== '' && $_mdMtime > 0) {
@mkdir(dirname($_cacheFile), 0755, true);
@file_put_contents($_cacheFile, json_encode(
['ts' => $_mdMtime, 'html' => $_renderedContent, 'toc' => $_tocItems],
JSON_UNESCAPED_UNICODE
));
}
}
ob_start();