Commit initial

This commit is contained in:
2026-03-07 11:50:19 +01:00
commit 18d97e0c5a
15 changed files with 1584 additions and 0 deletions

482
build.py Normal file
View 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()