Skip to content

Commit b5e94a5

Browse files
authored
Create generate_blog_index.py
1 parent 4c64cb6 commit b5e94a5

1 file changed

Lines changed: 141 additions & 0 deletions

File tree

scripts/generate_blog_index.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
#!/usr/bin/env python3
2+
"""Génère docs/blog/index.md en listant les posts du dossier docs/blog/posts/.
3+
4+
Scanne les fichiers Markdown (ou sans extension) dans docs/blog/posts/,
5+
lit leur front-matter YAML (title, date, author, category) et produit un
6+
index trié par date décroissante. Préserve tout contenu manuel situé
7+
au-dessus de la sentinelle <!-- blog-index:generated:start -->.
8+
9+
Aucune dépendance externe : le front-matter est parsé à la main.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import sys
15+
from datetime import date
16+
from pathlib import Path
17+
18+
# --- Configuration -----------------------------------------------------------
19+
POSTS_DIR = Path("docs/blog/posts")
20+
INDEX_FILE = Path("docs/blog/index.md")
21+
START_MARKER = "<!-- blog-index:generated:start -->"
22+
END_MARKER = "<!-- blog-index:generated:end -->"
23+
24+
25+
# --- Parsing front-matter ----------------------------------------------------
26+
def parse_front_matter(text: str) -> tuple[dict, str]:
27+
"""Retourne (metadata, body). metadata = dict simple des champs YAML."""
28+
lines = text.splitlines()
29+
if not lines or lines[0].strip() != "---":
30+
return {}, text
31+
meta: dict[str, str] = {}
32+
i = 1
33+
while i < len(lines) and lines[i].strip() != "---":
34+
line = lines[i]
35+
if ":" in line:
36+
key, _, value = line.partition(":")
37+
meta[key.strip()] = value.strip().strip('"').strip("'")
38+
i += 1
39+
body = "\n".join(lines[i + 1 :]).lstrip("\n") if i < len(lines) else ""
40+
return meta, body
41+
42+
43+
def parse_date(value: str) -> date:
44+
"""Convertit 'YYYY-MM-DD' en date. Échoue proprement si invalide."""
45+
return date.fromisoformat(value.strip())
46+
47+
48+
# --- Lecture des posts --------------------------------------------------------
49+
def collect_posts() -> list[dict]:
50+
posts = []
51+
for path in sorted(POSTS_DIR.iterdir()):
52+
if not path.is_file():
53+
continue
54+
# On accepte les .md et les fichiers sans extension (ex: open-curriculum)
55+
if path.suffix not in ("", ".md"):
56+
continue
57+
text = path.read_text(encoding="utf-8")
58+
meta, body = parse_front_matter(text)
59+
if not meta.get("date") or not meta.get("title"):
60+
continue # post incomplet, on l'ignore
61+
try:
62+
d = parse_date(meta["date"])
63+
except ValueError:
64+
print(f"⚠️ Date invalide dans {path.name}, ignoré", file=sys.stderr)
65+
continue
66+
slug = path.stem if path.suffix else path.name
67+
posts.append(
68+
{
69+
"title": meta.get("title", path.stem),
70+
"date": d,
71+
"date_str": d.isoformat(),
72+
"author": meta.get("author", ""),
73+
"category": meta.get("category", ""),
74+
"slug": slug,
75+
"body": body,
76+
}
77+
)
78+
posts.sort(key=lambda p: p["date"], reverse=True)
79+
return posts
80+
81+
82+
def excerpt(body: str, max_chars: int = 200) -> str:
83+
"""Extrait le premier paragraphe de prose comme aperçu."""
84+
for block in body.split("\n\n"):
85+
block = block.strip()
86+
if block and not block.startswith(("![", "|", "#")):
87+
# Coupe propre et ajoute des points de suspension si tronqué
88+
return block if len(block) <= max_chars else block[:max_chars].rsplit(" ", 1)[0] + "…"
89+
return ""
90+
91+
92+
# --- Génération de l'index ---------------------------------------------------
93+
def render_index(posts: list[dict]) -> str:
94+
lines = ["# Blog\n"]
95+
for p in posts:
96+
d = p["date"].strftime("%d %B %Y")
97+
link = f"posts/{p['slug']}/"
98+
lines.append(f"### [{p['title']}]({link})\n")
99+
meta_bits = [f"**{d}**"]
100+
if p["author"]:
101+
meta_bits.append(f"par {p['author']}")
102+
if p["category"]:
103+
meta_bits.append(f"· {p['category']}")
104+
lines.append(" ".join(meta_bits) + "\n")
105+
ex = excerpt(p["body"])
106+
if ex:
107+
lines.append(ex + "\n")
108+
lines.append("---\n")
109+
return "\n".join(lines).rstrip() + "\n"
110+
111+
112+
def write_index(content: str) -> None:
113+
"""Écrit l'index en préservant le contenu manuel au-dessus des marqueurs."""
114+
generated_block = f"{START_MARKER}\n{content}{END_MARKER}\n"
115+
if INDEX_FILE.exists():
116+
text = INDEX_FILE.read_text(encoding="utf-8")
117+
if START_MARKER in text and END_MARKER in text:
118+
pre = text.split(START_MARKER)[0]
119+
post = text.split(END_MARKER, 1)[1]
120+
INDEX_FILE.write_text(pre + generated_block + post, encoding="utf-8")
121+
return
122+
# Premier cas : le fichier est vide ou n'a pas de sentinelle
123+
INDEX_FILE.parent.mkdir(parents=True, exist_ok=True)
124+
INDEX_FILE.write_text(generated_block, encoding="utf-8")
125+
126+
127+
def main() -> int:
128+
if not POSTS_DIR.is_dir():
129+
print(f"❌ Dossier introuvable : {POSTS_DIR}", file=sys.stderr)
130+
return 1
131+
posts = collect_posts()
132+
if not posts:
133+
print("⚠️ Aucun post trouvé", file=sys.stderr)
134+
return 1
135+
write_index(render_index(posts))
136+
print(f"✅ {len(posts)} posts indexés dans {INDEX_FILE}")
137+
return 0
138+
139+
140+
if __name__ == "__main__":
141+
raise SystemExit(main())

0 commit comments

Comments
 (0)