|
| 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