#!/usr/bin/env python3 """ Générateur de site statique — PageMark Prérequis : pip install markdown Utilisation : python3 build.py ── Métadonnées ─────────────────────────────────────────────────────────────── ::Titre:Mon article ::Date:01/12/2026 ::Auteur:Pseudo ::Categories:Principale-SousCategorie ::Image:/images/cover.jpg ← optionnel, image de couverture pour les cards ── Fichiers de layout ──────────────────────────────────────────────────────── md/header.md Ligne 1 : # Nom du site | Reste : tagline md/footer.md Pied de page md/menu.md Navigation md/categories.md Mapping NomCategorie:dossier ── Sortie ──────────────────────────────────────────────────────────────────── index.html ← homepage auto (5 derniers articles) md/pages/index.md ← contenu affiché AU-DESSUS des articles md/pages/article.md ← article/index.html md/pages/article.md (catégorie) ← cat/sous-cat/article/index.html """ import re import sys from datetime import datetime from pathlib import Path try: import markdown except ImportError: print("Erreur : installez la dépendance avec : pip install markdown") sys.exit(1) BASE_DIR = Path(__file__).parent MD_DIR = BASE_DIR / 'md' PAGES_DIR = MD_DIR / 'pages' MD_EXTENSIONS = ['fenced_code', 'tables', 'attr_list', 'toc'] MD_EXTENSION_CONFIGS = { 'toc': { 'permalink': '#', # symbole cliquable affiché après le titre 'permalink_class': 'heading-anchor', 'toc_depth': '1-4', # h1 à h4 } } # ── Markdown ────────────────────────────────────────────────────────────────── def parse_md(text): md = markdown.Markdown( extensions=MD_EXTENSIONS, extension_configs=MD_EXTENSION_CONFIGS, output_format='html5' ) return md.convert(text) # ── Métadonnées ─────────────────────────────────────────────────────────────── _META_RE = re.compile(r'^::([^:]+):(.*)$') def parse_metadata(text): meta = {} lines = text.splitlines(keepends=True) i = 0 for line in lines: stripped = line.rstrip('\n') m = _META_RE.match(stripped) if m: meta[m.group(1).strip()] = m.group(2).strip() i += 1 elif stripped.strip() == '': i += 1 else: break return meta, ''.join(lines[i:]) # ── Catégories ──────────────────────────────────────────────────────────────── def load_categories(): cat_file = MD_DIR / 'categories.md' cats = {} if not cat_file.exists(): return cats for line in cat_file.read_text('utf-8').splitlines(): line = line.strip().lstrip('-*').strip() if not line or line.startswith('#'): continue if ':' in line: name, folder = line.split(':', 1) cats[name.strip().lower()] = folder.strip().lstrip('/') return cats def categories_to_path(categories_str, cat_map): parts = [p.strip() for p in categories_str.split('-') if p.strip()] if not parts: return None result = [] for i, part in enumerate(parts): result.append(cat_map.get(part.lower(), part.lower()) if i == 0 else part.lower()) return '/'.join(result) def categories_to_display(categories_str): parts = [p.strip() for p in categories_str.split('-') if p.strip()] return ' > '.join(parts) # ── Header du site ──────────────────────────────────────────────────────────── def extract_site_name(header_md): """Extrait le premier # Heading comme nom/logo du site.""" if not header_md: return '' m = re.search(r'^#\s+(.+)$', header_md, re.MULTILINE) return m.group(1).strip() if m else '' def extract_tagline_html(header_md): """Extrait tout ce qui suit le premier # Heading comme tagline.""" if not header_md: return '' content = re.sub(r'^#[^\n]*\n?', '', header_md, count=1, flags=re.MULTILINE).strip() return parse_md(content) if content else '' # ── Articles ────────────────────────────────────────────────────────────────── def parse_date(date_str): try: return datetime.strptime(date_str.strip(), '%d/%m/%Y') except Exception: return datetime.min def extract_excerpt(content_md, max_chars=180): """Extrait le premier paragraphe lisible du markdown.""" text = re.sub(r'```[\s\S]*?```', '', content_md) text = re.sub(r'^#{1,6}\s+.*$', '', text, flags=re.MULTILINE) text = re.sub(r'<[^>]+>', '', text) text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text) text = re.sub(r'[*_]{1,3}', '', text) paragraphs = [p.strip().replace('\n', ' ') for p in text.split('\n\n') if p.strip()] if not paragraphs: return '' excerpt = paragraphs[0] if len(excerpt) > max_chars: excerpt = excerpt[:max_chars].rsplit(' ', 1)[0] + '\u2026' return excerpt def build_article_card_html(article, featured=False): img_html = '' if article.get('image'): img_html = (f'
' f'{article[' f'
') meta_parts = [] if article.get('categories_display'): meta_parts.append(f'{article["categories_display"]}') if article.get('date_str'): meta_parts.append(f'{article["date_str"]}') if article.get('author'): meta_parts.append(f'{article["author"]}') sep = ' \u00b7 ' card_meta = ('
' + sep.join(meta_parts) + '
') if meta_parts else '' excerpt_html = f'

{article["excerpt"]}

' if article.get('excerpt') else '' if featured: return ( f'' ) else: return ( f'
' f'{img_html}' f'
' f'{card_meta}' f'

{article["title"]}

' f'{excerpt_html}' f'
' f'
' ) def generate_homepage_html(articles, intro_html=''): intro = f'
{intro_html}
' if intro_html else '' if not articles: return intro + '

Aucun article publié pour l\'instant.

' featured = articles[0] recent = articles[1:5] featured_section = ( f'' ) recent_section = '' if recent: cards = '\n'.join(build_article_card_html(a) for a in recent) recent_section = ( f'
' f'

Récents

' f'
{cards}
' f'
' ) return intro + featured_section + recent_section # ── Fichiers de layout ──────────────────────────────────────────────────────── def read_layout(name): for p in [MD_DIR / f'{name}.md', MD_DIR / name]: if p.exists(): return p.read_text('utf-8') return None # ── Menu ────────────────────────────────────────────────────────────────────── def is_external(url): return bool(re.match(r'^https?://', url)) def url_to_slug(url): slug = url.strip('/') return slug if slug else 'index' def build_menu_html(existing_slugs): text = read_layout('menu') if not text: return '' links = re.findall(r'\[([^\]]+)\]\(([^)]+)\)', text) items = [] for label, url in links: if is_external(url): items.append( f'
  • {label} \u2197
  • ' ) else: if url_to_slug(url) in existing_slugs: items.append(f'
  • {label}
  • ') return '' if items else '' # ── Page HTML ───────────────────────────────────────────────────────────────── def extract_title(md_text): m = re.search(r'^#\s+(.+)$', md_text, re.MULTILINE) return m.group(1).strip() if m else 'Page' def render_meta_block(meta): if not meta: return '' parts = [] titre = meta.get('Titre', '') if titre: parts.append(f'

    {titre}

    ') info = [] if meta.get('Date'): info.append(f'{meta["Date"]}') if meta.get('Auteur'): info.append(f'{meta["Auteur"]}') if meta.get('Categories'): info.append( f'' f'{categories_to_display(meta["Categories"])}' f'' ) if info: sep = ' \u2014 ' parts.append(f'
    {sep.join(info)}
    ') return '
    ' + ''.join(parts) + '
    ' if parts else '' def render_page(meta_html, content_html, title, site_name, tagline_html, menu_html, footer_html): logo = f'' if site_name else '' nav_links = f'' if menu_html else '
    ' nav = ( f'' ) tagline = ( f'
    ' f'{tagline_html}' f'
    ' ) if tagline_html else '' divider = '

    ' footer = ( f'' ) if footer_html else '' return f""" {title} {nav} {tagline} {divider}
    {meta_html} {content_html}
    {footer} """ # ── Chemin de sortie ────────────────────────────────────────────────────────── def output_path(slug, meta, cat_map): base_name = slug.split('/')[-1] categories_str = meta.get('Categories', '') if categories_str: cat_path = categories_to_path(categories_str, cat_map) if cat_path: if base_name == 'index': return BASE_DIR / cat_path / 'index.html' return BASE_DIR / cat_path / base_name / 'index.html' if slug == 'index': return BASE_DIR / 'index.html' return BASE_DIR / slug / 'index.html' # ── Collecte des pages ──────────────────────────────────────────────────────── def collect_pages(): pages = {} if not PAGES_DIR.exists(): return pages for f in sorted(PAGES_DIR.rglob('*')): if not f.is_file(): continue rel = f.relative_to(PAGES_DIR) slug = str(rel.with_suffix('')) if f.suffix == '.md' else str(rel) pages[slug.replace('\\', '/')] = f return pages # ── Build ───────────────────────────────────────────────────────────────────── def build(): raw_pages = collect_pages() if not raw_pages: print(f"Aucune page trouvée dans {PAGES_DIR}") return cat_map = load_categories() # Lecture et parsing de toutes les pages all_pages = {} for slug, md_file in raw_pages.items(): raw = md_file.read_text('utf-8') meta, content = parse_metadata(raw) all_pages[slug] = {'slug': slug, 'md_file': md_file, 'meta': meta, 'content': content} # Collecte des articles (pages avec ::Date:), triés du plus récent au plus ancien articles = [] for slug, page in all_pages.items(): if not page['meta'].get('Date'): continue out = output_path(slug, page['meta'], cat_map) rel = out.relative_to(BASE_DIR) parent = rel.parent.as_posix() url = '/' if parent == '.' else f'/{parent}/' articles.append({ 'slug': slug, 'url': url, 'title': page['meta'].get('Titre') or extract_title(page['content']), 'excerpt': extract_excerpt(page['content']), 'date': parse_date(page['meta'].get('Date', '')), 'date_str': page['meta'].get('Date', ''), 'author': page['meta'].get('Auteur', ''), 'categories_display': categories_to_display(page['meta'].get('Categories', '')) if page['meta'].get('Categories') else '', 'image': page['meta'].get('Image', ''), }) articles.sort(key=lambda a: a['date'], reverse=True) # Layout partagé header_md = read_layout('header') site_name = extract_site_name(header_md or '') tagline_html = extract_tagline_html(header_md or '') footer_html = parse_md(read_layout('footer') or '') # existing_slugs : slugs sources ET chemins de sortie réels # (le menu peut pointer vers /news/technologie/test/ qui est le chemin généré, # différent du slug source 'test') output_url_slugs = set() for slug, page in all_pages.items(): out = output_path(slug, page['meta'], cat_map) rel = out.relative_to(BASE_DIR) parent = rel.parent.as_posix() output_url_slugs.add('index' if parent == '.' else parent) existing_slugs = set(raw_pages.keys()) | {'index'} | output_url_slugs menu_html = build_menu_html(existing_slugs) print(f"Construction du site ({len(all_pages)} page(s), {len(articles)} article(s))...\n") # Rendu de toutes les pages for slug, page in sorted(all_pages.items()): meta = page['meta'] content = page['content'] if slug == 'index' and not meta.get('Date'): # Page d'accueil : contenu de index.md comme intro + listing articles intro_html = parse_md(content) if content.strip() else '' content_html = generate_homepage_html(articles, intro_html) meta_html = '' title = site_name or 'Accueil' out = BASE_DIR / 'index.html' else: content_html = parse_md(content) meta_html = render_meta_block(meta) title = meta.get('Titre') or extract_title(content) out = output_path(slug, meta, cat_map) out.parent.mkdir(parents=True, exist_ok=True) out.write_text( render_page(meta_html, content_html, title, site_name, tagline_html, menu_html, footer_html), encoding='utf-8' ) print(f" {str(page['md_file'].relative_to(BASE_DIR)):<42} \u2192 {out.relative_to(BASE_DIR)}") # Si pas de index.md, génère quand même la homepage if 'index' not in all_pages: content_html = generate_homepage_html(articles) out = BASE_DIR / 'index.html' out.write_text( render_page('', content_html, site_name or 'Accueil', site_name, tagline_html, menu_html, footer_html), encoding='utf-8' ) print(f" [homepage auto] \u2192 index.html") print(f"\n{len(all_pages)} page(s) g\u00e9n\u00e9r\u00e9e(s) ({len(articles)} article(s)).") print("Test local : python3 -m http.server 3000") if __name__ == '__main__': print("Construction du site...\n") build()