483 lines
18 KiB
Python
483 lines
18 KiB
Python
#!/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'<div class="card-image">'
|
|
f'<img src="{article["image"]}" alt="{article["title"]}" loading="lazy">'
|
|
f'</div>')
|
|
|
|
meta_parts = []
|
|
if article.get('categories_display'):
|
|
meta_parts.append(f'<span class="card-category">{article["categories_display"]}</span>')
|
|
if article.get('date_str'):
|
|
meta_parts.append(f'<span class="card-date">{article["date_str"]}</span>')
|
|
if article.get('author'):
|
|
meta_parts.append(f'<span class="card-author">{article["author"]}</span>')
|
|
|
|
sep = ' <span class="card-sep">\u00b7</span> '
|
|
card_meta = ('<div class="card-meta">' + sep.join(meta_parts) + '</div>') if meta_parts else ''
|
|
excerpt_html = f'<p class="card-excerpt">{article["excerpt"]}</p>' if article.get('excerpt') else ''
|
|
|
|
if featured:
|
|
return (
|
|
f'<article class="featured-card">'
|
|
f'{img_html}'
|
|
f'<div class="card-body">'
|
|
f'{card_meta}'
|
|
f'<h2 class="featured-title"><a href="{article["url"]}">{article["title"]}</a></h2>'
|
|
f'{excerpt_html}'
|
|
f'<a href="{article["url"]}" class="card-readmore">Lire l\'article \u2192</a>'
|
|
f'</div>'
|
|
f'</article>'
|
|
)
|
|
else:
|
|
return (
|
|
f'<article class="article-card">'
|
|
f'{img_html}'
|
|
f'<div class="card-body">'
|
|
f'{card_meta}'
|
|
f'<h3 class="card-title"><a href="{article["url"]}">{article["title"]}</a></h3>'
|
|
f'{excerpt_html}'
|
|
f'</div>'
|
|
f'</article>'
|
|
)
|
|
|
|
def generate_homepage_html(articles, intro_html=''):
|
|
intro = f'<div class="home-intro">{intro_html}</div>' if intro_html else ''
|
|
|
|
if not articles:
|
|
return intro + '<p class="no-articles">Aucun article publié pour l\'instant.</p>'
|
|
|
|
featured = articles[0]
|
|
recent = articles[1:5]
|
|
|
|
featured_section = (
|
|
f'<section class="home-featured">'
|
|
f'<h2 class="section-label">À la une</h2>'
|
|
f'{build_article_card_html(featured, featured=True)}'
|
|
f'</section>'
|
|
)
|
|
|
|
recent_section = ''
|
|
if recent:
|
|
cards = '\n'.join(build_article_card_html(a) for a in recent)
|
|
recent_section = (
|
|
f'<section class="home-recent">'
|
|
f'<h2 class="section-label">Récents</h2>'
|
|
f'<div class="articles-grid">{cards}</div>'
|
|
f'</section>'
|
|
)
|
|
|
|
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'<li><a href="{url}" class="external"'
|
|
f' target="_blank" rel="noopener noreferrer">{label} \u2197</a></li>'
|
|
)
|
|
else:
|
|
if url_to_slug(url) in existing_slugs:
|
|
items.append(f'<li><a href="{url}" class="internal">{label}</a></li>')
|
|
return '<ul>' + ''.join(items) + '</ul>' 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'<h1 class="article-title">{titre}</h1>')
|
|
info = []
|
|
if meta.get('Date'):
|
|
info.append(f'<span class="article-date">{meta["Date"]}</span>')
|
|
if meta.get('Auteur'):
|
|
info.append(f'<span class="article-author">{meta["Auteur"]}</span>')
|
|
if meta.get('Categories'):
|
|
info.append(
|
|
f'<span class="article-categories">'
|
|
f'{categories_to_display(meta["Categories"])}'
|
|
f'</span>'
|
|
)
|
|
if info:
|
|
sep = ' <span class="article-sep">\u2014</span> '
|
|
parts.append(f'<div class="article-info">{sep.join(info)}</div>')
|
|
return '<div class="article-meta">' + ''.join(parts) + '</div>' if parts else ''
|
|
|
|
def render_page(meta_html, content_html, title, site_name, tagline_html, menu_html, footer_html):
|
|
logo = f'<a href="/" class="site-logo">{site_name}</a>' if site_name else ''
|
|
nav_links = f'<div class="nav-links">{menu_html}</div>' if menu_html else '<div></div>'
|
|
|
|
nav = (
|
|
f'<nav class="site-nav">'
|
|
f'<div class="container nav-container">'
|
|
f'{logo}'
|
|
f'{nav_links}'
|
|
f'<button class="theme-toggle" id="theme-toggle"'
|
|
f' aria-label="Basculer le th\u00e8me">\u263e</button>'
|
|
f'</div></nav>'
|
|
)
|
|
|
|
tagline = (
|
|
f'<div class="site-tagline"><div class="container">'
|
|
f'{tagline_html}'
|
|
f'</div></div>'
|
|
) if tagline_html else ''
|
|
|
|
divider = '<div class="tagline-divider-wrap"><div class="container"><hr class="tagline-divider"></div></div>'
|
|
|
|
footer = (
|
|
f'<footer class="site-footer"><div class="container">{footer_html}</div></footer>'
|
|
) if footer_html else ''
|
|
|
|
return f"""<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{title}</title>
|
|
<script src="/static/theme.js"></script>
|
|
<link rel="stylesheet" href="/static/style.css">
|
|
<link id="hl-light" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
|
|
<link id="hl-dark" rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" media="not all">
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" defer></script>
|
|
<script>document.addEventListener("DOMContentLoaded", () => hljs.highlightAll())</script>
|
|
</head>
|
|
<body>
|
|
{nav}
|
|
{tagline}
|
|
{divider}
|
|
<main class="site-content">
|
|
<div class="container">
|
|
{meta_html}
|
|
{content_html}
|
|
</div>
|
|
</main>
|
|
{footer}
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
# ── 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()
|