- 14 chapitres + annexe en Markdown (chapitres/), 99 étapes - Générateur WeasyPrint (build/build.py) : PDF générique ou personnalisé via --vars fichier.conf - PDF générique + PDF personnalisé srv1.skglobalinstitute.com - Commandes v-* vérifiées contre le code source HestiaCP 1.10.4 - 03-bis-hestiacp.md pointe vers le nouveau guide Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018J2PsVQ28oW4xEkWJpcU9G
246 lines
9.8 KiB
Python
246 lines
9.8 KiB
Python
#!/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 <IP>, <DOMAINE>...)
|
|
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'<span class="var filled">{html.escape(values[key])}</span>'
|
|
return f'<span class="var"><{key}></span>'
|
|
|
|
return TOKEN_RE.sub(repl, html_text)
|
|
|
|
|
|
def convert_callouts(text):
|
|
"""> [!WARNING] Titre optionnel -> bloc <div class="callout warning">."""
|
|
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'<div class="callout {kind}" markdown="1">')
|
|
out.append(f'<p class="callout-title">{html.escape(title)}</p>\n')
|
|
out.extend(body)
|
|
out.append("</div>\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'<h1 id="{cid}" class="chapter-title"><span class="chap-no">{label}</span>'
|
|
f'<span class="chap-name">{title}</span></h1>')
|
|
|
|
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'<h2 id="{sid}" class="step"><span class="box"></span>{title}</h2>'
|
|
|
|
body = re.sub(r"<h1>(.*?)</h1>", h1, body, count=1)
|
|
body = re.sub(r"<h2>(.*?)</h2>", h2, body)
|
|
return f'<section class="chapter">{body}</section>', 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"""
|
|
<section class="cover">
|
|
<div class="cover-top">
|
|
<p class="kicker">Guide d'installation pas à pas</p>
|
|
<h1 class="cover-title">Serveur VPS<br>avec <span>HestiaCP</span></h1>
|
|
<p class="cover-sub">{html.escape(subtitle)}</p>
|
|
</div>
|
|
<div class="cover-meta">
|
|
<div><span>Panel</span><strong>HestiaCP {version}</strong></div>
|
|
<div><span>Système</span><strong>Ubuntu 24.04 LTS</strong></div>
|
|
<div><span>Pile</span><strong>Nginx · Apache · PHP-FPM · MariaDB · Exim · Dovecot · Bind</strong></div>
|
|
<div><span>Commandes vérifiées le</span><strong>{date}</strong></div>
|
|
</div>
|
|
<p class="cover-foot">Web · DNS · Mail · SSL · Sécurité · Docker · Supervision · Sauvegardes</p>
|
|
</section>"""
|
|
|
|
|
|
def vars_page(values):
|
|
rows = []
|
|
for key, (desc, example) in VARIABLES.items():
|
|
val = values.get(key, "")
|
|
cell = f'<td class="val filled">{html.escape(val)}</td>' if val else '<td class="val"></td>'
|
|
rows.append(f"<tr><td><code><{key}></code></td><td>{desc}</td>"
|
|
f"<td class=\"ex\">{example}</td>{cell}</tr>")
|
|
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 "
|
|
"<span class=\"var\"><VARIABLE></span> par votre valeur. Pour obtenir un PDF déjà "
|
|
"personnalisé, voir l'annexe « Régénérer ce guide ».")
|
|
return f"""
|
|
<section class="vars-page">
|
|
<h1 class="plain">Fiche du serveur</h1>
|
|
<p>{intro}</p>
|
|
<table class="vars"><thead><tr><th>Variable</th><th>Signification</th><th>Exemple</th><th>Votre valeur</th></tr></thead>
|
|
<tbody>{''.join(rows)}</tbody></table>
|
|
<div class="legend">
|
|
<p><strong>Conventions</strong></p>
|
|
<ul>
|
|
<li>Les commandes se copient telles quelles (pas d'invite <code>$</code>). Les lignes grises qui commencent par <code>#</code> sont des commentaires.</li>
|
|
<li>Chapitres 1 à 3 : connecté en <strong>root</strong>. À partir du chapitre 4 : avec votre utilisateur <span class="var"><USER></span> et <code>sudo</code>.</li>
|
|
<li>Chaque <strong>étape</strong> a une case à cocher : avancez dans l'ordre, une étape à la fois.</li>
|
|
<li>Les encadrés <em>Vérification</em> indiquent le résultat attendu avant de passer à la suite.</li>
|
|
</ul>
|
|
</div>
|
|
</section>"""
|
|
|
|
|
|
def toc_page(entries):
|
|
items = []
|
|
for kind, anchor, title, chap in entries:
|
|
title_txt = re.sub(r"<[^>]+>", "", title)
|
|
if kind == "chap":
|
|
items.append(f'<li class="toc-chap"><a href="#{anchor}"><span class="n">{chap}</span>{title_txt}</a></li>')
|
|
else:
|
|
items.append(f'<li class="toc-step"><a href="#{anchor}">{title_txt}</a></li>')
|
|
return f'<section class="toc"><h1 class="plain">Sommaire</h1><ol>{"".join(items)}</ol></section>'
|
|
|
|
|
|
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"""<!doctype html><html lang="fr"><head><meta charset="utf-8">
|
|
<title>{html.escape(doc_title)}</title><meta name="author" content="scriptshadow">
|
|
<link rel="stylesheet" href="style.css"></head><body>{body}</body></html>"""
|
|
|
|
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()
|