diff --git a/public/.htaccess b/public/.htaccess
index 97d66a6..f46705f 100644
--- a/public/.htaccess
+++ b/public/.htaccess
@@ -16,6 +16,9 @@ RewriteRule ^feed/?$ /feed.php [L,QSA]
RewriteRule ^rss/?$ /feed.php [L,QSA]
RewriteRule ^rss\.xml$ /feed.php [L,QSA]
+# Sitemap
+RewriteRule ^sitemap\.xml$ /sitemap.php [L]
+
# Ajoute .php si le fichier correspondant existe
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI}.php -f
RewriteRule ^(.+?)/?$ /$1.php [L,QSA]
diff --git a/public/index.php b/public/index.php
index b9320a7..239ddf0 100644
--- a/public/index.php
+++ b/public/index.php
@@ -21,6 +21,10 @@ $action = $_GET['action'] ?? 'list';
$uuid = $_GET['uuid'] ?? '';
$slug = $_GET['slug'] ?? '';
+$_noindexActions = ['create', 'edit', 'admin', 'categories', 'diff', 'add_files', 'import_image', 'import_image_step2', 'sources', 'profile', 'delete_file', 'delete_external_link', 'rename_category', 'delete_category', 'toggle_private_category'];
+$metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : null;
+unset($_noindexActions);
+
// ─── Extraction de métadonnées depuis une URL ────────────────────────────────
function fetchUrlMeta(string $url): array
{
diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 0000000..f3362e1
--- /dev/null
+++ b/public/robots.txt
@@ -0,0 +1,16 @@
+User-agent: *
+Disallow: /?action=edit
+Disallow: /?action=create
+Disallow: /?action=admin
+Disallow: /?action=delete
+Disallow: /?action=diff
+Disallow: /?action=categories
+Disallow: /?action=add_files
+Disallow: /?action=import_image
+Disallow: /?action=sources
+Disallow: /?action=profile
+Disallow: /login
+Disallow: /logout.php
+Disallow: /oidc/
+
+Sitemap: https://varlog.a5l.fr/sitemap.xml
diff --git a/public/sitemap.php b/public/sitemap.php
new file mode 100644
index 0000000..5ed94f9
--- /dev/null
+++ b/public/sitemap.php
@@ -0,0 +1,49 @@
+getPrivateCategories();
+
+$published = array_filter($articles->getAll(true), static function (array $a) use ($privateCats): bool {
+ $cat = trim($a['category'] ?? '');
+ if ($cat !== '' && in_array($cat, $privateCats, true)) {
+ return false;
+ }
+ if (strtotime((string)($a['published_at'] ?? '')) > time()) {
+ return false;
+ }
+ return true;
+});
+
+header('Content-Type: application/xml; charset=UTF-8');
+header('X-Robots-Tag: noindex');
+
+echo '' . "\n";
+echo '' . "\n";
+
+// Homepage
+echo ' ' . "\n";
+echo ' ' . htmlspecialchars(rtrim(APP_URL, '/') . '/') . '' . "\n";
+echo ' daily' . "\n";
+echo ' 1.0' . "\n";
+echo ' ' . "\n";
+
+foreach ($published as $article) {
+ $loc = htmlspecialchars(rtrim(APP_URL, '/') . '/post/' . rawurlencode($article['slug'] ?? ''));
+ $lastmod = date('Y-m-d', strtotime((string)($article['updated_at'] ?? $article['published_at'] ?? 'now')));
+ echo ' ' . "\n";
+ echo ' ' . $loc . '' . "\n";
+ echo ' ' . $lastmod . '' . "\n";
+ echo ' monthly' . "\n";
+ echo ' 0.8' . "\n";
+ echo ' ' . "\n";
+}
+
+echo '' . "\n";
diff --git a/templates/layout.php b/templates/layout.php
index 58e179f..ded0912 100644
--- a/templates/layout.php
+++ b/templates/layout.php
@@ -7,7 +7,8 @@
-
+
+
@@ -25,6 +26,10 @@
+
+
+
+
diff --git a/templates/post_list.php b/templates/post_list.php
index d2cbb63..c37891f 100644
--- a/templates/post_list.php
+++ b/templates/post_list.php
@@ -90,4 +90,28 @@ ob_start();
$filterCat]);
+} else {
+ $canonical = rtrim(APP_URL, '/') . '/';
+}
+
+// JSON-LD WebSite sur la page d'accueil sans filtre
+if (empty($cursor) && $filterCat === '') {
+ $jsonLd = json_encode([
+ '@context' => 'https://schema.org',
+ '@type' => 'WebSite',
+ 'name' => 'varlog',
+ 'url' => rtrim(APP_URL, '/') . '/',
+ 'description' => 'Journal personnel de Cédrix. Informatique, hack et loisirs techniques.',
+ 'inLanguage' => 'fr-FR',
+ 'author' => ['@type' => 'Person', 'name' => 'Cédrix'],
+ ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+}
+
include __DIR__ . '/layout.php';
diff --git a/templates/post_view.php b/templates/post_view.php
index 0ec8ca4..af2eb86 100644
--- a/templates/post_view.php
+++ b/templates/post_view.php
@@ -255,10 +255,45 @@ $hasSources = (!empty($externalLinks) || !empty($files))
$content = ob_get_clean();
$title = htmlspecialchars($article['title']);
$seoTitle = ($article['seo_title'] ?? '') ?: $article['title'];
-$seoDescription = $article['seo_description'] ?? '';
-$ogImage = $article['og_image'] ?? '';
$ogType = 'article';
$ogUrl = url('post/' . rawurlencode($article['slug'] ?? ''));
+$canonical = $ogUrl;
$articlePublishedAt = $article['published_at'] ?? '';
$mainClass = 'container-fluid';
+
+// Auto-description depuis le contenu si le champ SEO est vide
+$seoDescription = $article['seo_description'] ?? '';
+if ($seoDescription === '') {
+ $plain = strip_tags((new Parsedown())->text($article['content']));
+ $plain = preg_replace('/\s+/', ' ', $plain);
+ $seoDescription = mb_strimwidth(trim((string)$plain), 0, 155, '…');
+}
+
+// og:image : cover puis fallback og_image du meta
+if ($ogImage === null || $ogImage === '') {
+ $ogImage = $article['og_image'] ?? '';
+}
+
+// JSON-LD Article
+$jsonLdData = [
+ '@context' => 'https://schema.org',
+ '@type' => 'BlogPosting',
+ 'headline' => $seoTitle,
+ 'description' => $seoDescription,
+ 'url' => $canonical,
+ 'datePublished' => date('c', strtotime((string)$articlePublishedAt)),
+ 'dateModified' => date('c', strtotime((string)($article['updated_at'] ?? $articlePublishedAt))),
+ 'author' => ['@type' => 'Person', 'name' => 'Cédrix'],
+ 'publisher' => [
+ '@type' => 'Person',
+ 'name' => 'Cédrix',
+ 'url' => rtrim(APP_URL, '/'),
+ ],
+ 'inLanguage' => 'fr-FR',
+];
+if (!empty($ogImage)) {
+ $jsonLdData['image'] = $ogImage;
+}
+$jsonLd = json_encode($jsonLdData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+
include __DIR__ . '/layout.php';