homelab_automation/app/utils/help_renderer.py

521 lines
20 KiB
Python

"""
Renderer de documentation Markdown vers HTML avec styles TailwindCSS.
Convertit le fichier help.md en HTML pour la page d'aide du dashboard.
"""
import re
from pathlib import Path
from typing import List, Tuple, Optional
class HelpMarkdownRenderer:
"""Convertit le Markdown personnalisé en HTML avec styles TailwindCSS."""
def __init__(self, markdown_content: str):
self.content = markdown_content
self.toc_items: List[Tuple[str, str, str]] = [] # (id, emoji, title)
def render(self) -> Tuple[str, str]:
"""
Rend le Markdown en HTML.
Returns:
Tuple (html_content, toc_html)
"""
html = self.content
# Extraire et traiter les sections avec IDs pour la TOC
html = self._process_sections(html)
# Traiter les blocs personnalisés
html = self._process_quickstart_cards(html)
html = self._process_health_levels(html)
html = self._process_score_factors(html)
html = self._process_status_badges(html)
html = self._process_accordions(html)
html = self._process_playbooks(html)
html = self._process_troubleshoot(html)
# Traiter le Markdown standard
html = self._process_blockquotes(html)
html = self._process_tables(html)
html = self._process_code_blocks(html)
html = self._process_inline_code(html)
html = self._process_lists(html)
html = self._process_headings(html)
html = self._process_paragraphs(html)
html = self._process_bold_italic(html)
html = self._process_horizontal_rules(html)
# Générer la TOC
toc_html = self._generate_toc()
return html, toc_html
def _process_sections(self, html: str) -> str:
"""Extrait les sections H2 avec IDs pour la TOC."""
pattern = r'^## ([^\n{]+)\s*\{#([^}]+)\}'
def replace_section(match):
title = match.group(1).strip()
section_id = match.group(2).strip()
# Extraire l'emoji du titre
emoji_match = re.match(r'^(\S+)\s+(.+)$', title)
if emoji_match:
emoji = emoji_match.group(1)
text = emoji_match.group(2)
else:
emoji = ""
text = title
self.toc_items.append((section_id, emoji, text))
return f'</div><div id="{section_id}" class="glass-card p-8 mb-8 fade-in help-section-anchor">\n<h2 class="help-section-title"><span class="text-2xl mr-2">{emoji}</span>{text}</h2>'
html = re.sub(pattern, replace_section, html, flags=re.MULTILINE)
# Nettoyer le premier </div> en trop
html = html.replace('</div><div id="help-', '<div id="help-', 1)
# Ajouter la fermeture finale
html += '\n</div>'
return html
def _process_quickstart_cards(self, html: str) -> str:
"""Traite les cartes de démarrage rapide."""
# Trouver le bloc quickstart-cards
pattern = r'<!-- quickstart-cards -->(.*?)<!-- /quickstart-cards -->'
def process_cards(match):
cards_content = match.group(1)
# Parser chaque carte
card_pattern = r':::card\s+(\w+)\s+(fa-\d+)\s*\n###\s+([^\n]+)\n([^:]+):::'
cards = re.findall(card_pattern, cards_content, re.DOTALL)
cards_html = '<div class="grid grid-cols-1 md:grid-cols-3 gap-6">'
for color, icon, title, description in cards:
color_class = f"text-{color}-400"
desc = self._process_inline_code(description.strip())
cards_html += f'''
<div class="help-card">
<div class="text-3xl mb-4 {color_class}"><i class="fas {icon}"></i></div>
<h3 class="font-semibold mb-2">{title}</h3>
<p class="text-gray-400 text-sm">{desc}</p>
</div>'''
cards_html += '</div>'
return cards_html
return re.sub(pattern, process_cards, html, flags=re.DOTALL)
def _process_health_levels(self, html: str) -> str:
"""Traite les indicateurs de niveau de santé."""
pattern = r':::health-level\s+(\w+)\s+(\d+)\s+(\w+)\s*\n\*\*([^*]+)\*\*\s*\(([^)]+)\)\s*\n([^:]+):::'
def replace_health(match):
level = match.group(1)
bars = int(match.group(2))
color = match.group(3)
title = match.group(4)
subtitle = match.group(5)
description = match.group(6).strip()
bars_html = ''
for i in range(5):
if i < bars:
bars_html += f'<div class="health-bar bg-{color}-500"></div>'
else:
bars_html += '<div class="health-bar bg-gray-600"></div>'
return f'''
<div class="flex items-center gap-4 p-3 bg-gray-800/50 rounded-lg">
<div class="health-indicator-demo">{bars_html}</div>
<div>
<span class="font-semibold text-{color}-400">{title}</span>
<span class="text-gray-400 text-sm ml-2">({subtitle})</span>
<p class="text-gray-500 text-xs mt-1">{description}</p>
</div>
</div>'''
return re.sub(pattern, replace_health, html, flags=re.DOTALL)
def _process_score_factors(self, html: str) -> str:
"""Traite les facteurs de score."""
pattern = r':::score-factor\s+(fa-[\w-]+)\s+(\w+)\s+([^\n]+)\n\*\*([^*]+)\*\*\s*\n([^:]+):::'
def replace_factor(match):
icon = match.group(1)
color = match.group(2)
points = match.group(3).strip()
title = match.group(4)
description = match.group(5).strip()
return f'''
<div class="p-3 bg-gray-800/50 rounded-lg">
<div class="flex items-center gap-2 mb-2">
<i class="fas {icon} text-{color}-400 text-xs"></i>
<span class="font-medium">{title}</span>
<span class="text-{color}-400 text-sm ml-auto">{points}</span>
</div>
<p class="text-gray-500 text-xs">{description}</p>
</div>'''
return re.sub(pattern, replace_factor, html, flags=re.DOTALL)
def _process_status_badges(self, html: str) -> str:
"""Traite les badges de statut."""
pattern = r':::status-badge\s+(\w+)\s+(fa-[\w-]+)\s+([^\n]+)\n([^:]+):::'
def replace_badge(match):
color = match.group(1)
icon = match.group(2)
badge_text = match.group(3).strip()
description = match.group(4).strip()
description = self._process_inline_code(description)
return f'''
<div class="p-4 bg-{color}-900/20 border border-{color}-600/30 rounded-lg">
<div class="flex items-center gap-3 mb-2">
<span class="px-2 py-0.5 bg-{color}-600/30 text-{color}-400 text-xs rounded-full flex items-center">
<i class="fas {icon} mr-1"></i>{badge_text}
</span>
</div>
<p class="text-gray-400 text-sm">{description}</p>
</div>'''
return re.sub(pattern, replace_badge, html, flags=re.DOTALL)
def _process_accordions(self, html: str) -> str:
"""Traite les accordéons."""
pattern = r':::accordion\s+(fa-[\w-]+)\s+(\w+)\s+([^\n]+)\n(.*?):::'
def replace_accordion(match):
icon = match.group(1)
color = match.group(2)
title = match.group(3).strip()
content = match.group(4).strip()
# Traiter les listes dans le contenu
content = self._process_lists(content)
content = self._process_bold_italic(content)
# Séparer description et liste
parts = content.split('\n\n', 1)
description = parts[0] if parts else ''
list_content = parts[1] if len(parts) > 1 else ''
return f'''
<div class="accordion-item" onclick="toggleAccordion(this)">
<div class="accordion-header">
<span class="flex items-center gap-3">
<i class="fas {icon} text-{color}-400"></i>
<strong>{title}</strong>
</span>
<i class="fas fa-chevron-down accordion-icon text-gray-400"></i>
</div>
<div class="accordion-content">
<div class="p-4">
<p class="text-gray-400 mb-4">{description}</p>
{list_content}
</div>
</div>
</div>'''
return re.sub(pattern, replace_accordion, html, flags=re.DOTALL)
def _process_playbooks(self, html: str) -> str:
"""Traite les cartes de playbooks."""
pattern = r':::playbook\s+(fa-[\w-]+)\s+(\w+)\s+([^\n]+)\n([^*]+)\*\*([^*]+)\*\*\s*:::'
def replace_playbook(match):
icon = match.group(1)
color = match.group(2)
filename = match.group(3).strip()
description = match.group(4).strip()
note = match.group(5).strip()
# Déterminer la couleur de la note
note_color = "purple"
if "redémarrage" in note.lower() or "attention" in note.lower():
note_color = "orange"
return f'''
<div class="help-card">
<h3 class="font-semibold mb-3 flex items-center gap-2">
<i class="fas {icon} text-{color}-400"></i>
{filename}
</h3>
<p class="text-gray-400 text-sm mb-2">{description}</p>
<span class="text-xs text-{note_color}-400">{note}</span>
</div>'''
return re.sub(pattern, replace_playbook, html, flags=re.DOTALL)
def _process_troubleshoot(self, html: str) -> str:
"""Traite les sections de dépannage."""
pattern = r':::troubleshoot\s+([^\n]+)\n(.*?):::'
def replace_troubleshoot(match):
title = match.group(1).strip()
content = match.group(2).strip()
# Traiter le contenu
content = self._process_bold_italic(content)
content = self._process_inline_code(content)
content = content.replace('\n\n', '</p><p class="text-gray-400 text-sm">')
return f'''
<div class="accordion-item" onclick="toggleAccordion(this)">
<div class="accordion-header">
<span>{title}</span>
<i class="fas fa-chevron-down accordion-icon text-gray-400"></i>
</div>
<div class="accordion-content">
<div class="p-4 text-gray-400 text-sm">
<p class="text-gray-400 text-sm">{content}</p>
</div>
</div>
</div>'''
return re.sub(pattern, replace_troubleshoot, html, flags=re.DOTALL)
def _process_blockquotes(self, html: str) -> str:
"""Traite les citations (blockquotes)."""
pattern = r'^>\s*\*\*([^:]+):\*\*\s*(.+)$'
def replace_quote(match):
label = match.group(1)
content = match.group(2).strip()
content = self._process_inline_code(content)
return f'''
<div class="mt-4 p-3 bg-purple-900/20 border border-purple-600/30 rounded-lg">
<p class="text-sm text-purple-300">
<i class="fas fa-info-circle mr-2"></i>
<strong>{label}:</strong> {content}
</p>
</div>'''
return re.sub(pattern, replace_quote, html, flags=re.MULTILINE)
def _process_tables(self, html: str) -> str:
"""Traite les tableaux Markdown."""
# Trouver les tableaux
table_pattern = r'(\|[^\n]+\|\n)+(\|[-:| ]+\|\n)(\|[^\n]+\|\n)+'
def replace_table(match):
table_text = match.group(0)
lines = [l.strip() for l in table_text.strip().split('\n') if l.strip()]
if len(lines) < 3:
return table_text
# En-têtes
headers = [h.strip() for h in lines[0].split('|') if h.strip()]
# Ignorer la ligne de séparation (lines[1])
# Données
rows = []
for line in lines[2:]:
cells = [c.strip() for c in line.split('|') if c.strip()]
rows.append(cells)
# Construire le HTML
html_table = '<div class="overflow-x-auto"><table class="w-full text-sm">'
html_table += '<thead><tr class="border-b border-gray-700">'
for h in headers:
html_table += f'<th class="text-left py-3 px-4 text-purple-400">{h}</th>'
html_table += '</tr></thead>'
html_table += '<tbody class="text-gray-300">'
for row in rows:
html_table += '<tr class="border-b border-gray-800">'
for cell in row:
cell = self._process_inline_code(cell)
html_table += f'<td class="py-3 px-4">{cell}</td>'
html_table += '</tr>'
html_table += '</tbody></table></div>'
return html_table
return re.sub(table_pattern, replace_table, html)
def _process_code_blocks(self, html: str) -> str:
"""Traite les blocs de code."""
pattern = r'```(\w*)\n(.*?)```'
def replace_code(match):
lang = match.group(1) or ''
code = match.group(2)
return f'<div class="bg-black/40 p-4 rounded-lg font-mono text-sm"><pre class="text-gray-300">{code}</pre></div>'
return re.sub(pattern, replace_code, html, flags=re.DOTALL)
def _process_inline_code(self, html: str) -> str:
"""Traite le code inline."""
return re.sub(r'`([^`]+)`', r'<span class="help-code">\1</span>', html)
def _process_lists(self, html: str) -> str:
"""Traite les listes à puces."""
lines = html.split('\n')
result = []
in_list = False
for line in lines:
if line.strip().startswith('- '):
if not in_list:
result.append('<ul class="help-list text-sm">')
in_list = True
item = line.strip()[2:]
item = self._process_bold_italic(item)
item = self._process_inline_code(item)
result.append(f'<li>{item}</li>')
else:
if in_list:
result.append('</ul>')
in_list = False
result.append(line)
if in_list:
result.append('</ul>')
return '\n'.join(result)
def _process_headings(self, html: str) -> str:
"""Traite les titres H3 et H4."""
# H3
html = re.sub(
r'^### ([^\n{]+)$',
r'<h3 class="font-semibold mb-4 text-purple-400">\1</h3>',
html,
flags=re.MULTILINE
)
# H4
html = re.sub(
r'^#### ([^\n]+)$',
r'<h4 class="font-semibold mb-2 flex items-center gap-2"><i class="fas fa-question-circle text-gray-400"></i>\1</h4>',
html,
flags=re.MULTILINE
)
return html
def _process_paragraphs(self, html: str) -> str:
"""Traite les paragraphes."""
# Remplacer les lignes vides multiples par des paragraphes
html = re.sub(r'\n\n+', '\n\n', html)
# Envelopper les paragraphes isolés
lines = html.split('\n\n')
result = []
for block in lines:
block = block.strip()
if not block:
continue
# Ne pas envelopper si c'est déjà du HTML
if block.startswith('<') or block.startswith('|'):
result.append(block)
elif not any(block.startswith(x) for x in ['#', '-', '>', '```', ':::', '<!--']):
result.append(f'<p class="text-gray-400 mb-6">{block}</p>')
else:
result.append(block)
return '\n\n'.join(result)
def _process_bold_italic(self, html: str) -> str:
"""Traite le gras et l'italique."""
# Gras
html = re.sub(r'\*\*([^*]+)\*\*', r'<strong>\1</strong>', html)
# Italique
html = re.sub(r'\*([^*]+)\*', r'<em>\1</em>', html)
return html
def _process_horizontal_rules(self, html: str) -> str:
"""Traite les lignes horizontales."""
return re.sub(r'^---+$', '', html, flags=re.MULTILINE)
def _generate_toc(self) -> str:
"""Génère le HTML de la table des matières."""
toc_html = ''
for section_id, emoji, title in self.toc_items:
toc_html += f'''
<a href="#{section_id}" class="help-toc-item" onclick="scrollToHelpSection(event, '{section_id}')">
<span class="mr-2">{emoji}</span>{title}
</a>'''
return toc_html
def render_help_page(markdown_path: Path) -> Tuple[str, str]:
"""
Rend la page d'aide depuis le fichier Markdown.
Args:
markdown_path: Chemin vers le fichier help.md
Returns:
Tuple (html_content, toc_html)
"""
if not markdown_path.exists():
return "<p>Documentation non disponible.</p>", ""
content = markdown_path.read_text(encoding='utf-8')
renderer = HelpMarkdownRenderer(content)
return renderer.render()
def get_raw_markdown(markdown_path: Path) -> str:
"""
Retourne le contenu Markdown brut nettoyé pour le téléchargement.
Args:
markdown_path: Chemin vers le fichier help.md
Returns:
Contenu Markdown nettoyé (sans syntaxe personnalisée)
"""
if not markdown_path.exists():
return "# Documentation non disponible"
content = markdown_path.read_text(encoding='utf-8')
# Nettoyer la syntaxe personnalisée pour un Markdown standard
# Supprimer les IDs de section
content = re.sub(r'\s*\{#[^}]+\}', '', content)
# Convertir les cartes en texte simple
content = re.sub(r'<!-- quickstart-cards -->', '', content)
content = re.sub(r'<!-- /quickstart-cards -->', '', content)
content = re.sub(r':::card\s+\w+\s+fa-\d+\s*\n', '', content)
# Convertir les health-levels
content = re.sub(r':::health-level\s+\w+\s+\d+\s+\w+\s*\n', '', content)
# Convertir les score-factors
content = re.sub(r':::score-factor\s+[^\n]+\n', '', content)
# Convertir les status-badges
content = re.sub(r':::status-badge\s+[^\n]+\n', '', content)
# Convertir les accordions
content = re.sub(r':::accordion\s+[^\n]+\n', '### ', content)
# Convertir les playbooks
content = re.sub(r':::playbook\s+[^\n]+\n', '### ', content)
# Convertir les troubleshoot
content = re.sub(r':::troubleshoot\s+', '### ', content)
# Supprimer les fermetures :::
content = re.sub(r'^:::$', '', content, flags=re.MULTILINE)
# Supprimer les attributs {icon:...}
content = re.sub(r'\s*\{icon:[^}]+\}', '', content)
# Nettoyer les lignes vides multiples
content = re.sub(r'\n{3,}', '\n\n', content)
return content.strip()