521 lines
20 KiB
Python
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()
|