🔒 Repository is read-only – file editing is disabled.

PaganLinux/pagan-web-v2/markdown_render.py main

128 linii Raw ← Powrót
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
"""Wspólny renderer Markdown dla portalu PaganOS (docs / about / git README).

Poza zwykłym Markdownem obsługuje diagramy Mermaid — bloki kodu oznaczone
```mermaid (np. `graph TD`, `sequenceDiagram`, …) są zamieniane na elementy
`<pre class="mermaid">`, które rysuje mermaid.js dołączony w `base.html`.

Renderowanie diagramów odbywa się PO STRONIE PRZEGLĄDARKI, więc tutaj nie
potrzebujemy żadnej zależności JS — tylko przekazujemy definicję w odpowiednim
znaczniku. Treść pozostaje HTML-escape'owana (mermaid czyta `textContent`,
więc przeglądarka najpierw odkoduje encje) — to bezpieczne dla treści
pochodzących od użytkowników.

Ten moduł celowo nie importuje Flask/Blueprintów, aby móc go używać zarówno
w `app.py`, jak i w `blueprints/*` bez cyklicznych importów.
"""
import re
import html as _html

try:
    import mistune
    _HAS_MISTUNE = True
except ImportError:  # pragma: no cover – środowisko bez mistune (fallback)
    mistune = None
    _HAS_MISTUNE = False

# mistune (v2/v3) renderuje ```mermaid jako <pre><code class="language-mermaid">.
# Zamieniamy opakowanie na <pre class="mermaid"> (zostawiając escape'owaną treść).
_MERMAID_BLOCK_RE = re.compile(
    r'<pre><code class="language-mermaid">(.*?)</code></pre>',
    re.DOTALL | re.IGNORECASE,
)

# Cache instancji renderera per (escape, plugins) – tworzenie mistune jest tanie,
# ale trzymanie jednej instancji jest czystsze i szybsze.
_MD_INSTANCES = {}


def to_mermaid(html_text: str) -> str:
    """Zamienia bloki ```mermaid w znaczniki renderowane przez mermaid.js."""
    if not html_text or "language-mermaid" not in html_text.lower():
        return html_text
    return _MERMAID_BLOCK_RE.sub(
        lambda m: '<pre class="mermaid">' + m.group(1) + '</pre>',
        html_text,
    )


# ── Spis treści ──────────────────────────────────────────────────────────────
# mistune NIE nadaje nagłówkom atrybutu id, więc linki "spisu treści" typu
# [Szybki start](#szybki-start) prowadziły donikąd. Dodajemy id z tekstu
# nagłówka, wg algorytmu zbliżonego do GitHub (polskie znaki zachowane).
_TAG_RE = re.compile(r'<[^>]+>')
_HEADING_RE = re.compile(r'<h([1-6])>(.*?)</h\1>', re.DOTALL)
# Usuwamy interpunkcję, zostawiamy litery/cyfry/_/-, spacje i znaki Unicode.
_SLUG_STRIP_RE = re.compile(r'[^\w -]', re.UNICODE)


def _slugify(text: str) -> str:
    """Zamienia tekst nagłówka na anchor zgodny z linkami TOC (jak GitHub).

    - lowercase, bez interpunkcji (backticki, `/`, `.`, `–`, `:` itd.)
    - KAŻDA spacja → `-` (wielokrotne spacje dają wielokrotne myślniki,
      dokładnie jak w GitHub/Gitea – dlatego `#...--pacnew--pacsave` działa)
    """
    text = _html.unescape(text).strip().lower()
    text = _SLUG_STRIP_RE.sub('', text)
    return text.replace(' ', '-')


def add_heading_ids(html_text: str) -> str:
    """Dodaje `id` do <h1>..<h6> na podstawie tekstu (dla spisu treści)."""
    if not html_text or '<h' not in html_text:
        return html_text
    seen = {}

    def _repl(m):
        level, inner = m.group(1), m.group(2)
        slug = _slugify(_TAG_RE.sub('', inner))
        if not slug:
            return m.group(0)
        if slug in seen:
            seen[slug] += 1
            slug = f"{slug}-{seen[slug]}"
        else:
            seen[slug] = 0
        return f'<h{level} id="{slug}">{inner}</h{level}>'

    return _HEADING_RE.sub(_repl, html_text)


# Pluginy GFM włączane ZAWSZE (obok przekazanych przez wywołującego).
# 'table' jest krytyczny: mistune 3 domyślnie NIE renderuje tabel pipe — bez
# niego `| a | b |` z README/docs wyświetla się jako zwykły akapit.
_ALWAYS_PLUGINS = ("table",)


def _get_markdown(escape: bool, plugins):
    effective = list(plugins) if plugins else []
    for _p in _ALWAYS_PLUGINS:
        if _p not in effective:
            effective.append(_p)
    key = (bool(escape), tuple(effective))
    md = _MD_INSTANCES.get(key)
    if md is None:
        md = mistune.create_markdown(
            escape=escape,
            plugins=effective,
        )
        _MD_INSTANCES[key] = md
    return md


def render_markdown(text: str, *, escape: bool = False, plugins=None) -> str:
    """Renderuje Markdown + tabele GFM + Mermaid + identyfikatory nagłówków.

    escape/plugins odpowiadają argumentom mistune.create_markdown, dzięki czemu
    można zachować dotychczasowe zachowanie poszczególnych widoków:
      - docs/about:  render_markdown(text)                        (bez escape, bez pluginów)
      - README/pliki repo: render_markdown(text, escape=True, plugins=[...])

    Plugin 'table' jest dodawany zawsze (patrz _ALWAYS_PLUGINS).
    """
    if not text:
        return ""
    if not _HAS_MISTUNE:
        # Bez mistune nie ma HTML-a — zwracamy surowy tekst jak dotychczas.
        return text
    return add_heading_ids(to_mermaid(_get_markdown(escape, plugins)(text)))