#!/usr/bin/env python3 """Génère le guide PDF HestiaCP à partir des chapitres Markdown. Usage : python3 build/build.py # PDF générique (variables , ...) python3 build/build.py --vars mon-vps.conf # PDF personnalisé avec les valeurs du VPS python3 build/build.py --html # garde aussi le HTML intermédiaire (debug) Dépendances : pip install weasyprint markdown pygments """ import argparse import datetime import html import re import sys from pathlib import Path import markdown from weasyprint import HTML ROOT = Path(__file__).resolve().parent.parent BUILD = ROOT / "build" CHAPTERS = ROOT / "chapitres" # Variables du guide : clé -> (description, exemple) VARIABLES = { "IP": ("Adresse IPv4 publique du VPS", "203.0.113.10"), "IPV6": ("Adresse IPv6 publique du VPS", "2001:db8::10"), "DOMAINE": ("Domaine principal du serveur", "exemple.com"), "FQDN": ("Nom complet du serveur (hostname)", "srv1.exemple.com"), "HOST": ("Nom court du serveur", "srv1"), "USER": ("Utilisateur Linux sudo (SSH)", "jean"), "ADMIN": ("Administrateur du panel HestiaCP", "hadmin"), "EMAIL": ("E-mail de l'administrateur", "admin@exemple.com"), "SSH_PORT": ("Port SSH personnalisé", "50022"), "PANEL_PORT": ("Port du panel HestiaCP", "8083"), "CLIENT": ("Utilisateur Hestia qui héberge les projets", "client1"), "SITE": ("Domaine d'un projet hébergé", "monprojet.com"), "BACKUP_HOST": ("Serveur de sauvegarde distant (SFTP)", "backup.exemple.net"), "BACKUP_USER": ("Utilisateur SFTP de la sauvegarde", "sauvegarde"), "BACKUP_PORT": ("Port SSH du serveur de sauvegarde", "22"), } TOKEN = "ZZV{}ZZ" TOKEN_RE = re.compile(r"ZZV([A-Z0-9_]+?)ZZ") PLACEHOLDER_RE = re.compile(r"\{\{([A-Z0-9_]+)\}\}") CALLOUTS = { "NOTE": ("note", "À savoir"), "TIP": ("tip", "Astuce"), "WARNING": ("warning", "Attention"), "DANGER": ("danger", "Danger"), "CHECK": ("check", "Vérification"), } def load_vars(path): values = {} for line in Path(path).read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, val = line.split("=", 1) values[key.strip().upper()] = val.strip().strip('"').strip("'") return values def protect_placeholders(text): def repl(m): key = m.group(1) if key not in VARIABLES: sys.exit(f"Variable inconnue dans le guide : {{{{{key}}}}}") return TOKEN.format(key) return PLACEHOLDER_RE.sub(repl, text) def render_placeholders(html_text, values): def repl(m): key = m.group(1) if key in values and values[key]: return f'{html.escape(values[key])}' return f'<{key}>' return TOKEN_RE.sub(repl, html_text) def convert_callouts(text): """> [!WARNING] Titre optionnel -> bloc
.""" out, lines, i = [], text.split("\n"), 0 while i < len(lines): m = re.match(r"^>\s*\[!(\w+)\]\s*(.*)$", lines[i]) if m and m.group(1).upper() in CALLOUTS: kind, label = CALLOUTS[m.group(1).upper()] title = m.group(2).strip() or label body = [] i += 1 while i < len(lines) and lines[i].startswith(">"): body.append(re.sub(r"^>\s?", "", lines[i])) i += 1 out.append(f'
') out.append(f'

{html.escape(title)}

\n') out.extend(body) out.append("
\n") continue out.append(lines[i]) i += 1 return "\n".join(out) def slug(text, prefix): s = re.sub(r"<[^>]+>", "", text) s = re.sub(r"[^\w]+", "-", s.lower(), flags=re.UNICODE).strip("-") return f"{prefix}-{s}"[:80] def build_chapter(md_text, chap_no, label): md_text = convert_callouts(protect_placeholders(md_text)) body = markdown.markdown( md_text, extensions=["fenced_code", "tables", "attr_list", "md_in_html", "codehilite", "sane_lists"], extension_configs={"codehilite": {"guess_lang": False, "css_class": "code"}}, ) toc = [] step_counter = {"n": 0} def h1(m): title = m.group(1) cid = f"chap-{chap_no}" toc.append(("chap", cid, title, "A" if label == "Annexe" else chap_no)) return (f'

{label}' f'{title}

') def h2(m): title = m.group(1) step_counter["n"] += 1 sid = slug(title, f"s{chap_no}-{step_counter['n']}") toc.append(("step", sid, title, chap_no)) return f'

{title}

' body = re.sub(r"

(.*?)

", h1, body, count=1) body = re.sub(r"

(.*?)

", h2, body) return f'
{body}
', toc def cover(values, version, date): personal = bool(values) subtitle = values.get("FQDN") if personal else "Guide réutilisable pour tout VPS Ubuntu 24.04" return f"""

Guide d'installation pas à pas

Serveur VPS
avec HestiaCP

{html.escape(subtitle)}

PanelHestiaCP {version}
SystèmeUbuntu 24.04 LTS
PileNginx · Apache · PHP-FPM · MariaDB · Exim · Dovecot · Bind
Commandes vérifiées le{date}

Web · DNS · Mail · SSL · Sécurité · Docker · Supervision · Sauvegardes

""" def vars_page(values): rows = [] for key, (desc, example) in VARIABLES.items(): val = values.get(key, "") cell = f'{html.escape(val)}' if val else '' rows.append(f"<{key}>{desc}" f"{example}{cell}") intro = ("Ce PDF a été généré avec les valeurs de ce serveur." if values else "Remplissez la dernière colonne avant de commencer. Dans tout le guide, remplacez chaque " "<VARIABLE> par votre valeur. Pour obtenir un PDF déjà " "personnalisé, voir l'annexe « Régénérer ce guide ».") return f"""

Fiche du serveur

{intro}

{''.join(rows)}
VariableSignificationExempleVotre valeur

Conventions

  • Les commandes se copient telles quelles (pas d'invite $). Les lignes grises qui commencent par # sont des commentaires.
  • Chapitres 1 à 3 : connecté en root. À partir du chapitre 4 : avec votre utilisateur <USER> et sudo.
  • Chaque étape a une case à cocher : avancez dans l'ordre, une étape à la fois.
  • Les encadrés Vérification indiquent le résultat attendu avant de passer à la suite.
""" def toc_page(entries): items = [] for kind, anchor, title, chap in entries: title_txt = re.sub(r"<[^>]+>", "", title) if kind == "chap": items.append(f'
  • {chap}{title_txt}
  • ') else: items.append(f'
  • {title_txt}
  • ') return f'

    Sommaire

      {"".join(items)}
    ' def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--vars", help="fichier de variables (voir vps.conf.exemple)") ap.add_argument("--out", help="chemin du PDF de sortie") ap.add_argument("--html", action="store_true", help="écrire aussi le HTML intermédiaire") args = ap.parse_args() values = load_vars(args.vars) if args.vars else {} meta = load_vars(BUILD / "meta.conf") version, date = meta.get("HESTIA_VERSION", "?"), meta.get("VERIFIED_ON", datetime.date.today().isoformat()) chapters, toc = [], [] for no, path in enumerate(sorted(CHAPTERS.glob("*.md")), start=1): label = "Annexe" if path.name.startswith("99") else f"Chapitre {no}" html_chap, entries = build_chapter(path.read_text(encoding="utf-8"), no, label) chapters.append(html_chap) toc.extend(entries) body = cover(values, version, date) + vars_page(values) + toc_page(toc) + "".join(chapters) body = render_placeholders(body, values) doc_title = "Guide VPS HestiaCP" + (f" — {values['FQDN']}" if values.get("FQDN") else "") page = f""" {html.escape(doc_title)} {body}""" if args.out: out = Path(args.out) elif values.get("FQDN"): out = ROOT / f"Guide-VPS-HestiaCP-{values['FQDN']}.pdf" else: out = ROOT / "Guide-VPS-HestiaCP.pdf" if args.html: (BUILD / "guide.html").write_text(page, encoding="utf-8") HTML(string=page, base_url=str(BUILD)).write_pdf(out) print(f"PDF généré : {out}") if __name__ == "__main__": main()