#!/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''
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''
f'{img_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''
f'
À la une
'
f'{build_article_card_html(featured, featured=True)}'
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'
' 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'{site_name}' if site_name else ''
nav_links = f'
{menu_html}
' if menu_html else ''
nav = (
f''
)
tagline = (
f'