Ajout du guide PDF HestiaCP réutilisable (HestiaCP 1.10.4)

- 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
This commit is contained in:
Zana
2026-09-10 20:01:59 +02:00
parent acbe11489c
commit 2e18724685
31 changed files with 2419 additions and 44 deletions

245
hestiacp/build/build.py Normal file
View File

@@ -0,0 +1,245 @@
#!/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">&lt;{key}&gt;</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>&lt;{key}&gt;</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\">&lt;VARIABLE&gt;</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">&lt;USER&gt;</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()