Commit initial
This commit is contained in:
482
build.py
Normal file
482
build.py
Normal file
@@ -0,0 +1,482 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user