refactor: réorganiser Dockerfiles et scripts par module
Réorganise la structure Docker pour plus de cohérence dans le monorepo. Chaque module (backend, docs) a maintenant ses propres Dockerfiles et scripts. Changements: - backend/docker/ : Dockerfile (prod) + dev.Dockerfile (hot reload) + init script - docs/docker/ : mkdocs.Dockerfile + pdf.Dockerfile - docs/scripts/ : generate-bdd-docs.py + generate-pdf-docs.py - Déplace docker-compose.yml dans backend/ - Supprime scripts obsolètes (fix-markdown-*.sh, remove-broken-links.sh) - Déplace .dockerignore à la racine - Met à jour Makefile avec nouveaux chemins Organisation finale: - backend/ : tout ce qui concerne l'API backend - docs/ : tout ce qui concerne la documentation - scripts/ : uniquement setup.sh (scripts généraux du projet)
This commit is contained in:
419
docs/scripts/generate-bdd-docs.py
Normal file
419
docs/scripts/generate-bdd-docs.py
Normal file
@@ -0,0 +1,419 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script pour générer la documentation Markdown à partir des fichiers Gherkin (.feature)
|
||||
Convertit les fichiers .feature en fichiers .md pour MkDocs Material
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Step:
|
||||
"""Représente une étape Gherkin"""
|
||||
keyword: str # Étant donné, Quand, Alors, Et, Mais
|
||||
text: str
|
||||
table: List[List[str]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scenario:
|
||||
"""Représente un scénario ou plan de scénario"""
|
||||
title: str
|
||||
is_outline: bool
|
||||
steps: List[Step] = field(default_factory=list)
|
||||
examples: List[List[str]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Feature:
|
||||
"""Représente une fonctionnalité complète"""
|
||||
title: str
|
||||
description: str = ""
|
||||
context_steps: List[Step] = field(default_factory=list)
|
||||
scenarios: List[Scenario] = field(default_factory=list)
|
||||
|
||||
|
||||
def parse_table(lines: List[str], start_idx: int) -> Tuple[List[List[str]], int]:
|
||||
"""Parse une table Gherkin et retourne (table, nouvel index)"""
|
||||
table = []
|
||||
idx = start_idx
|
||||
while idx < len(lines) and lines[idx].strip().startswith('|'):
|
||||
row = [cell.strip() for cell in lines[idx].strip().strip('|').split('|')]
|
||||
table.append(row)
|
||||
idx += 1
|
||||
return table, idx
|
||||
|
||||
|
||||
def parse_feature_content(content: str) -> Feature:
|
||||
"""Parse le contenu d'un fichier .feature"""
|
||||
# Supprimer la ligne de langage
|
||||
content = re.sub(r'^# language: \w+\n', '', content)
|
||||
|
||||
lines = content.split('\n')
|
||||
feature = Feature(title="")
|
||||
|
||||
i = 0
|
||||
current_scenario: Optional[Scenario] = None
|
||||
in_context = False
|
||||
in_examples = False
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
|
||||
# Ligne vide
|
||||
if not stripped:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Fonctionnalité
|
||||
if stripped.startswith('Fonctionnalité:'):
|
||||
feature.title = stripped[len('Fonctionnalité:'):].strip()
|
||||
i += 1
|
||||
# Capturer la description (lignes indentées qui suivent)
|
||||
desc_lines = []
|
||||
while i < len(lines) and lines[i].startswith(' ') and not lines[i].strip().startswith(('Contexte:', 'Scénario:', 'Plan du Scénario:')):
|
||||
desc_lines.append(lines[i].strip())
|
||||
i += 1
|
||||
feature.description = '\n'.join(desc_lines)
|
||||
continue
|
||||
|
||||
# Contexte
|
||||
if stripped == 'Contexte:':
|
||||
in_context = True
|
||||
in_examples = False
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Plan du Scénario
|
||||
if stripped.startswith('Plan du Scénario:'):
|
||||
if current_scenario:
|
||||
feature.scenarios.append(current_scenario)
|
||||
current_scenario = Scenario(
|
||||
title=stripped[len('Plan du Scénario:'):].strip(),
|
||||
is_outline=True
|
||||
)
|
||||
in_context = False
|
||||
in_examples = False
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Scénario
|
||||
if stripped.startswith('Scénario:'):
|
||||
if current_scenario:
|
||||
feature.scenarios.append(current_scenario)
|
||||
current_scenario = Scenario(
|
||||
title=stripped[len('Scénario:'):].strip(),
|
||||
is_outline=False
|
||||
)
|
||||
in_context = False
|
||||
in_examples = False
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Exemples (pour Plan du Scénario)
|
||||
if stripped == 'Exemples:':
|
||||
in_examples = True
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Table d'exemples
|
||||
if in_examples and stripped.startswith('|') and current_scenario:
|
||||
table, i = parse_table(lines, i)
|
||||
current_scenario.examples = table
|
||||
in_examples = False
|
||||
continue
|
||||
|
||||
# Étapes Gherkin
|
||||
keywords = ['Étant donné', 'Quand', 'Alors', 'Et', 'Mais']
|
||||
matched_keyword = None
|
||||
for kw in keywords:
|
||||
if stripped.startswith(kw):
|
||||
matched_keyword = kw
|
||||
break
|
||||
|
||||
if matched_keyword:
|
||||
step_text = stripped[len(matched_keyword):].strip()
|
||||
step = Step(keyword=matched_keyword, text=step_text)
|
||||
|
||||
# Vérifier si une table suit
|
||||
if i + 1 < len(lines) and lines[i + 1].strip().startswith('|'):
|
||||
table, i = parse_table(lines, i + 1)
|
||||
step.table = table
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# Ajouter l'étape au bon endroit
|
||||
if in_context:
|
||||
feature.context_steps.append(step)
|
||||
elif current_scenario:
|
||||
current_scenario.steps.append(step)
|
||||
continue
|
||||
|
||||
i += 1
|
||||
|
||||
# Ajouter le dernier scénario
|
||||
if current_scenario:
|
||||
feature.scenarios.append(current_scenario)
|
||||
|
||||
return feature
|
||||
|
||||
|
||||
def format_table_markdown(table: List[List[str]], indent: str = "") -> str:
|
||||
"""Formate une table en Markdown"""
|
||||
if not table:
|
||||
return ""
|
||||
|
||||
lines = []
|
||||
# En-tête
|
||||
lines.append(f"{indent}| " + " | ".join(table[0]) + " |")
|
||||
# Séparateur
|
||||
lines.append(f"{indent}|" + "|".join(["---" for _ in table[0]]) + "|")
|
||||
# Lignes de données
|
||||
for row in table[1:]:
|
||||
lines.append(f"{indent}| " + " | ".join(row) + " |")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def get_keyword_color(keyword: str) -> str:
|
||||
"""Retourne une couleur pour chaque type de mot-clé"""
|
||||
colors = {
|
||||
'Étant donné': '#2196F3', # Bleu
|
||||
'Quand': '#FF9800', # Orange
|
||||
'Alors': '#4CAF50', # Vert
|
||||
'Et': '#9E9E9E', # Gris
|
||||
'Mais': '#F44336' # Rouge
|
||||
}
|
||||
return colors.get(keyword, '#000000')
|
||||
|
||||
|
||||
def format_step(step: Step, indent: str = "") -> str:
|
||||
"""Formate une étape Gherkin avec couleur"""
|
||||
color = get_keyword_color(step.keyword)
|
||||
# Ajouter deux espaces à la fin pour forcer un line break en markdown
|
||||
result = f'{indent}<span style="color: {color}">**{step.keyword}**</span> {step.text} '
|
||||
|
||||
if step.table:
|
||||
result += "\n\n"
|
||||
# Pas d'indentation pour les tableaux (sinon ils deviennent des blocs de code)
|
||||
result += format_table_markdown(step.table, "")
|
||||
result += "\n"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def generate_markdown(feature: Feature) -> str:
|
||||
"""Génère le contenu Markdown pour une fonctionnalité"""
|
||||
lines = []
|
||||
|
||||
# Titre de la fonctionnalité
|
||||
lines.append(f"# {feature.title}\n")
|
||||
|
||||
# Description en tant que user story (bloc quote formaté proprement)
|
||||
if feature.description:
|
||||
desc_lines = feature.description.split('\n')
|
||||
for desc_line in desc_lines:
|
||||
lines.append(f"> *{desc_line}*\n")
|
||||
lines.append("\n")
|
||||
|
||||
# Statistiques
|
||||
scenario_count = len(feature.scenarios)
|
||||
outline_count = sum(1 for s in feature.scenarios if s.is_outline)
|
||||
regular_count = scenario_count - outline_count
|
||||
|
||||
stats_parts = [f"**{scenario_count} scénario{'s' if scenario_count > 1 else ''}**"]
|
||||
if outline_count > 0:
|
||||
stats_parts.append(f" ({regular_count} standard{'s' if regular_count > 1 else ''}, {outline_count} plan{'s' if outline_count > 1 else ''})")
|
||||
lines.append("".join(stats_parts) + "\n\n")
|
||||
|
||||
lines.append("---\n\n")
|
||||
|
||||
# Contexte
|
||||
if feature.context_steps:
|
||||
lines.append('!!! info "Contexte commun à tous les scénarios"\n\n')
|
||||
for step in feature.context_steps:
|
||||
lines.append(format_step(step, " ") + "\n")
|
||||
lines.append("\n")
|
||||
|
||||
# Scénarios
|
||||
for idx, scenario in enumerate(feature.scenarios, 1):
|
||||
# Header h2 pour chaque scénario
|
||||
if scenario.is_outline:
|
||||
lines.append(f"## {idx}. 📋 Plan: {scenario.title}\n\n")
|
||||
else:
|
||||
lines.append(f"## {idx}. {scenario.title}\n\n")
|
||||
|
||||
# Regrouper les étapes par section (Given/When/Then)
|
||||
current_section = None
|
||||
for step in scenario.steps:
|
||||
# Déterminer la section
|
||||
if step.keyword == 'Étant donné':
|
||||
section = 'given'
|
||||
elif step.keyword == 'Quand':
|
||||
section = 'when'
|
||||
elif step.keyword == 'Alors':
|
||||
section = 'then'
|
||||
else:
|
||||
section = current_section # Et/Mais gardent la section précédente
|
||||
|
||||
# Ajouter un séparateur visuel entre les sections
|
||||
if section != current_section and current_section is not None:
|
||||
lines.append("\n")
|
||||
current_section = section
|
||||
|
||||
lines.append(format_step(step, "") + "\n")
|
||||
|
||||
# Exemples pour les plans de scénario
|
||||
if scenario.is_outline and scenario.examples:
|
||||
lines.append("\n**📊 Exemples de données:**\n\n")
|
||||
lines.append(format_table_markdown(scenario.examples, "") + "\n")
|
||||
|
||||
lines.append("\n---\n\n")
|
||||
|
||||
return ''.join(lines)
|
||||
|
||||
|
||||
def generate_index(features_dir: Path, output_dir: Path) -> None:
|
||||
"""Génère un fichier index.md listant toutes les fonctionnalités"""
|
||||
categories: Dict[str, List[Tuple[str, Path, int, int]]] = {}
|
||||
|
||||
for feature_file in features_dir.rglob('*.feature'):
|
||||
category = feature_file.parent.name
|
||||
relative_path = feature_file.relative_to(features_dir)
|
||||
md_path = relative_path.with_suffix('.md')
|
||||
|
||||
# Extraire le titre et compter les scénarios
|
||||
with open(feature_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
feature_match = re.search(r'^Fonctionnalité: (.+)$', content, re.MULTILINE)
|
||||
title = feature_match.group(1) if feature_match else feature_file.stem
|
||||
|
||||
scenario_count = len(re.findall(r'^\s{2}Scénario:', content, re.MULTILINE))
|
||||
outline_count = len(re.findall(r'^\s{2}Plan du Scénario:', content, re.MULTILINE))
|
||||
|
||||
if category not in categories:
|
||||
categories[category] = []
|
||||
categories[category].append((title, md_path, scenario_count, outline_count))
|
||||
|
||||
# Calculer les totaux
|
||||
total_features = sum(len(cat) for cat in categories.values())
|
||||
total_scenarios = sum(s + o for cat in categories.values() for _, _, s, o in cat)
|
||||
|
||||
# Générer l'index
|
||||
index_lines = [
|
||||
"# Tests BDD - Documentation des fonctionnalités\n\n",
|
||||
"Cette documentation est générée automatiquement à partir des fichiers Gherkin (`.feature`).\n\n",
|
||||
"## Vue d'ensemble\n\n",
|
||||
f"| Métrique | Valeur |\n",
|
||||
f"|----------|--------|\n",
|
||||
f"| Fonctionnalités | **{total_features}** |\n",
|
||||
f"| Scénarios | **{total_scenarios}** |\n",
|
||||
f"| Domaines métier | **{len(categories)}** |\n\n",
|
||||
"---\n\n"
|
||||
]
|
||||
|
||||
# Icônes par catégorie
|
||||
category_icons = {
|
||||
'authentication': '🔐',
|
||||
'recommendation': '🎯',
|
||||
'interest-gauges': '📊',
|
||||
'content-creation': '🎨',
|
||||
'navigation': '🧭',
|
||||
'publicites': '📢',
|
||||
'radio-live': '📻',
|
||||
'abonnements': '🔔',
|
||||
'monetisation': '💰',
|
||||
'premium': '⭐',
|
||||
'mode-offline': '📴',
|
||||
'error-handling': '⚠️',
|
||||
'rgpd-compliance': '🔒',
|
||||
'moderation': '🛡️',
|
||||
'partage': '🔗',
|
||||
'profil': '👤',
|
||||
'recherche': '🔍',
|
||||
'audio-guides': '🎧',
|
||||
}
|
||||
|
||||
for category in sorted(categories.keys()):
|
||||
category_title = category.replace('-', ' ').replace('_', ' ').title()
|
||||
icon = category_icons.get(category, '📁')
|
||||
cat_scenarios = sum(s + o for _, _, s, o in categories[category])
|
||||
|
||||
index_lines.append(f"## {icon} {category_title}\n\n")
|
||||
|
||||
# Carte de la catégorie
|
||||
index_lines.append(f"| Fonctionnalité | Scénarios |\n")
|
||||
index_lines.append(f"|----------------|:---------:|\n")
|
||||
|
||||
for title, md_path, scenario_count, outline_count in sorted(categories[category]):
|
||||
link = str(md_path).replace('\\', '/')
|
||||
total = scenario_count + outline_count
|
||||
index_lines.append(f"| [{title}]({link}) | {total} |\n")
|
||||
|
||||
index_lines.append(f"\n*{len(categories[category])} fonctionnalités • {cat_scenarios} scénarios*\n\n")
|
||||
|
||||
# Écrire l'index
|
||||
index_file = output_dir / 'index.md'
|
||||
with open(index_file, 'w', encoding='utf-8') as f:
|
||||
f.writelines(index_lines)
|
||||
|
||||
print(f"✓ Index généré: {index_file}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Point d'entrée principal"""
|
||||
# Chemins
|
||||
project_root = Path(__file__).parent.parent
|
||||
features_dir = project_root / 'docs' / 'domains'
|
||||
output_dir = project_root / 'docs' / 'generated' / 'bdd'
|
||||
|
||||
# Nettoyer le dossier de sortie
|
||||
if output_dir.exists():
|
||||
import shutil
|
||||
shutil.rmtree(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"📁 Génération de la documentation BDD...")
|
||||
print(f" Source: {features_dir}")
|
||||
print(f" Destination: {output_dir}\n")
|
||||
|
||||
# Convertir tous les fichiers .feature
|
||||
feature_files = list(features_dir.rglob('*.feature'))
|
||||
count = 0
|
||||
|
||||
for feature_file in feature_files:
|
||||
# Créer la structure de dossiers
|
||||
relative_path = feature_file.relative_to(features_dir)
|
||||
md_file = output_dir / relative_path.with_suffix('.md')
|
||||
md_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Parser et convertir
|
||||
with open(feature_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
feature = parse_feature_content(content)
|
||||
markdown = generate_markdown(feature)
|
||||
|
||||
# Écrire le fichier
|
||||
with open(md_file, 'w', encoding='utf-8') as f:
|
||||
f.write(markdown)
|
||||
|
||||
count += 1
|
||||
print(f" ✓ {relative_path} → {md_file.relative_to(project_root)}")
|
||||
|
||||
print(f"\n✓ {count} fichiers .feature convertis en Markdown")
|
||||
|
||||
# Générer l'index
|
||||
print("\n📋 Génération de l'index...")
|
||||
generate_index(features_dir, output_dir)
|
||||
|
||||
print(f"\n✅ Documentation BDD générée avec succès dans {output_dir}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
376
docs/scripts/generate-pdf-docs.py
Normal file
376
docs/scripts/generate-pdf-docs.py
Normal file
@@ -0,0 +1,376 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script pour générer un PDF complet de toute la documentation MkDocs.
|
||||
Utilise weasyprint pour convertir le HTML en PDF.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
import yaml
|
||||
|
||||
|
||||
class SafeLineLoader(yaml.SafeLoader):
|
||||
"""Loader YAML qui ignore les tags Python non supportés"""
|
||||
pass
|
||||
|
||||
|
||||
# Ignorer les tags Python custom (pour les extensions MkDocs)
|
||||
SafeLineLoader.add_multi_constructor('tag:yaml.org,2002:python/', lambda loader, suffix, node: None)
|
||||
|
||||
|
||||
def parse_nav(nav: List, docs_dir: Path, prefix: str = "") -> List[Path]:
|
||||
"""Parse la navigation MkDocs et retourne la liste ordonnée des fichiers MD"""
|
||||
files = []
|
||||
for item in nav:
|
||||
if isinstance(item, str):
|
||||
# Fichier simple ou dossier
|
||||
path = docs_dir / item
|
||||
if path.is_file() and path.suffix == '.md':
|
||||
files.append(path)
|
||||
elif path.is_dir():
|
||||
# C'est un dossier, récupérer tous les fichiers MD
|
||||
files.extend(sorted(path.rglob('*.md')))
|
||||
elif item.endswith('/'):
|
||||
# Référence à un dossier (ex: bdd/)
|
||||
dir_path = docs_dir / item.rstrip('/')
|
||||
if dir_path.is_dir():
|
||||
files.extend(sorted(dir_path.rglob('*.md')))
|
||||
elif isinstance(item, dict):
|
||||
for title, value in item.items():
|
||||
if isinstance(value, str):
|
||||
# Fichier avec titre ou dossier
|
||||
path = docs_dir / value
|
||||
if path.is_file() and path.suffix == '.md':
|
||||
files.append(path)
|
||||
elif path.is_dir():
|
||||
files.extend(sorted(path.rglob('*.md')))
|
||||
elif value.endswith('/'):
|
||||
dir_path = docs_dir / value.rstrip('/')
|
||||
if dir_path.is_dir():
|
||||
files.extend(sorted(dir_path.rglob('*.md')))
|
||||
elif isinstance(value, list):
|
||||
# Sous-section
|
||||
files.extend(parse_nav(value, docs_dir, prefix + " "))
|
||||
return files
|
||||
|
||||
|
||||
def get_all_md_files(docs_dir: Path, mkdocs_config: Dict[str, Any]) -> List[Path]:
|
||||
"""Récupère tous les fichiers MD dans l'ordre de navigation"""
|
||||
if 'nav' in mkdocs_config:
|
||||
return parse_nav(mkdocs_config['nav'], docs_dir)
|
||||
else:
|
||||
# Pas de nav, récupérer tous les fichiers MD
|
||||
return sorted(docs_dir.rglob('*.md'))
|
||||
|
||||
|
||||
def preprocess_markdown(content: str, file_path: Path, docs_dir: Path) -> str:
|
||||
"""Prétraite le Markdown pour le PDF"""
|
||||
# Convertir les liens relatifs
|
||||
def fix_link(match):
|
||||
link = match.group(2)
|
||||
if link.startswith('http'):
|
||||
return match.group(0)
|
||||
# Convertir les liens .md en ancres
|
||||
if link.endswith('.md'):
|
||||
link = link[:-3]
|
||||
return f'[{match.group(1)}](#{link})'
|
||||
|
||||
content = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', fix_link, content)
|
||||
|
||||
# Supprimer les admonitions MkDocs et les convertir en blockquotes
|
||||
# !!! type "title" -> blockquote
|
||||
content = re.sub(
|
||||
r'!!! (\w+) "([^"]+)"\n\n((?: .+\n)+)',
|
||||
lambda m: f'> **{m.group(2)}**\n>\n' + '\n'.join(f'> {line[4:]}' for line in m.group(3).split('\n') if line),
|
||||
content
|
||||
)
|
||||
|
||||
# ??? type "title" -> blockquote (collapsible)
|
||||
content = re.sub(
|
||||
r'\?\?\??\+? (\w+) "([^"]+)"\n\n((?: .+\n)+)',
|
||||
lambda m: f'> **{m.group(2)}**\n>\n' + '\n'.join(f'> {line[4:]}' for line in m.group(3).split('\n') if line),
|
||||
content
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def create_combined_markdown(files: List[Path], docs_dir: Path, output_path: Path) -> None:
|
||||
"""Combine tous les fichiers MD en un seul"""
|
||||
combined = []
|
||||
|
||||
# Page de titre
|
||||
combined.append("# Documentation RoadWave\n\n")
|
||||
combined.append("---\n\n")
|
||||
combined.append("## Table des matières\n\n")
|
||||
|
||||
# Générer la table des matières
|
||||
toc_entries = []
|
||||
for f in files:
|
||||
with open(f, 'r', encoding='utf-8') as file:
|
||||
content = file.read()
|
||||
# Extraire le titre H1
|
||||
title_match = re.search(r'^# (.+)$', content, re.MULTILINE)
|
||||
if title_match:
|
||||
title = title_match.group(1)
|
||||
anchor = re.sub(r'[^\w\s-]', '', title.lower()).replace(' ', '-')
|
||||
toc_entries.append(f"- [{title}](#{anchor})")
|
||||
|
||||
combined.append('\n'.join(toc_entries))
|
||||
combined.append("\n\n---\n\n")
|
||||
combined.append('<div style="page-break-after: always;"></div>\n\n')
|
||||
|
||||
# Ajouter chaque fichier
|
||||
for f in files:
|
||||
with open(f, 'r', encoding='utf-8') as file:
|
||||
content = file.read()
|
||||
|
||||
# Prétraiter
|
||||
content = preprocess_markdown(content, f, docs_dir)
|
||||
|
||||
combined.append(content)
|
||||
combined.append("\n\n")
|
||||
combined.append('<div style="page-break-after: always;"></div>\n\n')
|
||||
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(combined))
|
||||
|
||||
|
||||
def markdown_to_html(md_path: Path, html_path: Path) -> None:
|
||||
"""Convertit Markdown en HTML avec styles"""
|
||||
import markdown
|
||||
from markdown.extensions.tables import TableExtension
|
||||
from markdown.extensions.fenced_code import FencedCodeExtension
|
||||
from markdown.extensions.toc import TocExtension
|
||||
|
||||
with open(md_path, 'r', encoding='utf-8') as f:
|
||||
md_content = f.read()
|
||||
|
||||
# Convertir en HTML
|
||||
md = markdown.Markdown(extensions=[
|
||||
'tables',
|
||||
'fenced_code',
|
||||
'toc',
|
||||
'attr_list',
|
||||
'md_in_html'
|
||||
])
|
||||
html_content = md.convert(md_content)
|
||||
|
||||
# Template HTML avec styles
|
||||
html_template = f'''<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Documentation RoadWave</title>
|
||||
<style>
|
||||
@page {{
|
||||
size: A4;
|
||||
margin: 2cm;
|
||||
@bottom-center {{
|
||||
content: counter(page);
|
||||
}}
|
||||
}}
|
||||
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 11pt;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 100%;
|
||||
}}
|
||||
|
||||
h1 {{
|
||||
color: #1a237e;
|
||||
border-bottom: 3px solid #3f51b5;
|
||||
padding-bottom: 10px;
|
||||
page-break-after: avoid;
|
||||
font-size: 24pt;
|
||||
}}
|
||||
|
||||
h2 {{
|
||||
color: #303f9f;
|
||||
border-bottom: 1px solid #7986cb;
|
||||
padding-bottom: 5px;
|
||||
margin-top: 30px;
|
||||
page-break-after: avoid;
|
||||
font-size: 16pt;
|
||||
}}
|
||||
|
||||
h3 {{
|
||||
color: #3949ab;
|
||||
page-break-after: avoid;
|
||||
font-size: 13pt;
|
||||
}}
|
||||
|
||||
/* Couleurs Gherkin */
|
||||
span[style*="#2196F3"] {{ color: #1565c0 !important; font-weight: bold; }} /* Étant donné - Bleu */
|
||||
span[style*="#FF9800"] {{ color: #e65100 !important; font-weight: bold; }} /* Quand - Orange */
|
||||
span[style*="#4CAF50"] {{ color: #2e7d32 !important; font-weight: bold; }} /* Alors - Vert */
|
||||
span[style*="#9E9E9E"] {{ color: #616161 !important; }} /* Et - Gris */
|
||||
span[style*="#F44336"] {{ color: #c62828 !important; font-weight: bold; }} /* Mais - Rouge */
|
||||
|
||||
table {{
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 15px 0;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
|
||||
th, td {{
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}}
|
||||
|
||||
th {{
|
||||
background-color: #3f51b5;
|
||||
color: white;
|
||||
}}
|
||||
|
||||
tr:nth-child(even) {{
|
||||
background-color: #f5f5f5;
|
||||
}}
|
||||
|
||||
blockquote {{
|
||||
border-left: 4px solid #3f51b5;
|
||||
margin: 15px 0;
|
||||
padding: 10px 20px;
|
||||
background-color: #e8eaf6;
|
||||
font-style: italic;
|
||||
}}
|
||||
|
||||
code {{
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: "Fira Code", "Consolas", monospace;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
|
||||
pre {{
|
||||
background-color: #263238;
|
||||
color: #aed581;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
font-size: 9pt;
|
||||
}}
|
||||
|
||||
pre code {{
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}}
|
||||
|
||||
hr {{
|
||||
border: none;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
margin: 30px 0;
|
||||
}}
|
||||
|
||||
a {{
|
||||
color: #1976d2;
|
||||
text-decoration: none;
|
||||
}}
|
||||
|
||||
/* Info box (contexte) */
|
||||
.info-box {{
|
||||
background-color: #e3f2fd;
|
||||
border-left: 4px solid #2196f3;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
}}
|
||||
|
||||
/* Page breaks */
|
||||
.page-break {{
|
||||
page-break-after: always;
|
||||
}}
|
||||
|
||||
/* Cover page */
|
||||
.cover {{
|
||||
text-align: center;
|
||||
padding-top: 200px;
|
||||
}}
|
||||
|
||||
.cover h1 {{
|
||||
font-size: 36pt;
|
||||
border: none;
|
||||
}}
|
||||
|
||||
/* TOC */
|
||||
.toc {{
|
||||
page-break-after: always;
|
||||
}}
|
||||
|
||||
.toc ul {{
|
||||
list-style: none;
|
||||
padding-left: 20px;
|
||||
}}
|
||||
|
||||
.toc li {{
|
||||
margin: 5px 0;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{html_content}
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
with open(html_path, 'w', encoding='utf-8') as f:
|
||||
f.write(html_template)
|
||||
|
||||
|
||||
def html_to_pdf(html_path: Path, pdf_path: Path) -> None:
|
||||
"""Convertit HTML en PDF avec weasyprint"""
|
||||
from weasyprint import HTML, CSS
|
||||
|
||||
print(f" Conversion HTML → PDF...")
|
||||
HTML(filename=str(html_path)).write_pdf(str(pdf_path))
|
||||
|
||||
|
||||
def main():
|
||||
"""Point d'entrée principal"""
|
||||
project_root = Path(__file__).parent.parent
|
||||
docs_dir = project_root / 'docs'
|
||||
mkdocs_path = project_root / 'mkdocs.yml'
|
||||
output_dir = project_root / 'docs' / 'generated' / 'pdf'
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print("📄 Génération du PDF de la documentation RoadWave...")
|
||||
|
||||
# Charger la config MkDocs
|
||||
with open(mkdocs_path, 'r', encoding='utf-8') as f:
|
||||
mkdocs_config = yaml.load(f, Loader=SafeLineLoader)
|
||||
|
||||
# Récupérer tous les fichiers MD
|
||||
print(" Collecte des fichiers Markdown...")
|
||||
md_files = get_all_md_files(docs_dir, mkdocs_config)
|
||||
print(f" → {len(md_files)} fichiers trouvés")
|
||||
|
||||
# Créer un fichier MD combiné
|
||||
combined_md = output_dir / 'documentation_complete.md'
|
||||
print(" Combinaison des fichiers...")
|
||||
create_combined_markdown(md_files, docs_dir, combined_md)
|
||||
|
||||
# Convertir en HTML
|
||||
html_path = output_dir / 'documentation_complete.html'
|
||||
print(" Conversion Markdown → HTML...")
|
||||
markdown_to_html(combined_md, html_path)
|
||||
|
||||
# Convertir en PDF
|
||||
pdf_path = output_dir / 'RoadWave_Documentation.pdf'
|
||||
html_to_pdf(html_path, pdf_path)
|
||||
|
||||
print(f"\n✅ PDF généré: {pdf_path}")
|
||||
print(f" Taille: {pdf_path.stat().st_size / 1024 / 1024:.2f} MB")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user