munin/internal/markdown/markdown.go
2026-10-19 10:13:56 +02:00

148 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package markdown ist die eine Stelle, an der Munin Markdown in HTML wandelt —
// für Einträge (internal/entry) genauso wie für die eingebaute Hilfe
// (internal/help).
//
// # Warum ein eigenes Paket?
//
// Vorher hatten Einträge und Hilfe je einen eigenen goldmark-Aufruf mit
// unterschiedlichen Einstellungen. Die Folge: Eine Tabelle erschien in der
// Hilfe als Tabelle, im Eintrag aber als Text — und wer die Hilfe las, konnte
// nicht wissen, was im eigenen Eintrag funktioniert. Mit einem gemeinsamen
// Paket gilt eine Regel für beide (AUSBAUSTUFEN.md 5.3).
//
// # Sicherheitshinweis
//
// goldmark lässt in der Standardeinstellung eingebettetes Roh-HTML NICHT durch —
// aus <script>alert(1)</script> im Markdown wird ein Kommentar, kein Skript.
// Außerdem entfernt es gefährliche Link-Ziele wie "javascript:". Die
// Weboberfläche verlässt sich darauf, wenn sie das Ergebnis ohne weiteres
// Escaping einsetzt.
//
// Deshalb darf hier nie html.WithUnsafe() dazukommen. Als zweite
// Verteidigungslinie steht die Content-Security-Policy (siehe internal/web).
package markdown
import (
"bytes"
"fmt"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"munin/internal/store"
)
// newRenderer baut einen goldmark-Renderer mit den Erweiterungen, die überall
// gelten. Zusätzliche Parser-Optionen (etwa Überschrift-Anker) kommen als
// Parameter dazu.
//
// Das "..." vor dem Typ heißt in Go "beliebig viele Werte dieses Typs"
// (variadische Funktion) — man kann newRenderer() also auch ganz ohne
// Zusatzoptionen aufrufen.
func newRenderer(extra ...parser.Option) goldmark.Markdown {
return goldmark.New(
// GFM ("GitHub Flavored Markdown") bündelt vier Erweiterungen:
// Tabellen, ~~Durchstreichen~~, Checklisten (- [ ] / - [x]) und
// automatische Links für nackte Adressen wie https://example.org.
// Das ist, was die meisten aus GitHub oder GitLab gewohnt sind — und was
// Agenten ohnehin schreiben.
goldmark.WithExtensions(extension.GFM),
goldmark.WithParserOptions(extra...),
)
}
// Die beiden Renderer werden einmal beim Programmstart gebaut und dann für
// jede Anfrage wiederverwendet. goldmark erlaubt das ausdrücklich: Ein
// Renderer hat keinen Zustand, der sich zwischen zwei Aufrufen ändert, und ist
// damit auch bei gleichzeitigen Anfragen sicher.
var (
// withoutAnchors ist für Einträge. Überschriften bekommen hier bewusst KEINE
// id-Attribute: Eintragstexte kommen von Menschen und Agenten, und eine
// Überschrift wie "## suche" ergäbe id="suche" — die könnte mit einer id
// der Seite selbst zusammenstoßen, auf die sich das JavaScript verlässt.
withoutAnchors = newRenderer()
// withAnchors ist für die Hilfe. Jede Überschrift bekommt eine id aus ihrem
// Text ("## Markdown im Inhalt" → id="markdown-im-inhalt"), damit Links
// aus der Oberfläche direkt auf einen Abschnitt zeigen können
// (AUSBAUSTUFEN.md 5.4). Die Hilfeseiten liegen im Repository, ihre
// Überschriften sind also bekannt und unbedenklich.
withAnchors = newRenderer(parser.WithAutoHeadingID())
)
// Render wandelt Markdown in HTML, ohne Überschrift-Anker. Für Einträge.
func Render(source string) (string, error) {
return convert(withoutAnchors, source)
}
// RenderWithAnchors wandelt Markdown in HTML und gibt jeder Überschrift eine id.
// Für die Hilfe.
func RenderWithAnchors(source string) (string, error) {
// Jede Seite bekommt ihren eigenen Anker-Generator. Er merkt sich, welche
// ids schon vergeben sind — und das darf nicht in die nächste Seite
// hinüberwirken (und bei gleichzeitigen Anfragen schon gar nicht).
context := parser.NewContext(parser.WithIDs(&anchorIDs{assigned: map[string]bool{}}))
return convert(withAnchors, source, parser.WithContext(context))
}
// convert ist der gemeinsame Teil beider Funktionen.
func convert(renderer goldmark.Markdown, source string, options ...parser.ParseOption) (string, error) {
// bytes.Buffer ist ein wachsender Speicher, in den goldmark das Ergebnis
// schreibt. Das & übergibt einen Zeiger darauf, damit goldmark genau diesen
// Puffer füllt und nicht eine Kopie davon.
var buf bytes.Buffer
if err := renderer.Convert([]byte(source), &buf, options...); err != nil {
return "", fmt.Errorf("Markdown konnte nicht dargestellt werden: %w", err)
}
return buf.String(), nil
}
// anchorIDs erzeugt die id-Attribute der Überschriften in der Hilfe.
//
// # Warum nicht goldmarks eingebaute Anker?
//
// goldmark lässt alle Zeichen außerhalb von a–z und 0–9 einfach weg. Aus
// "## Zugänge verwalten" würde so id="zugnge-verwalten" — unschön, und wer
// einen Link darauf schreibt, rät garantiert falsch. store.Slugify faltet
// Umlaute dagegen so, wie man es erwartet (ä → ae, ß → ss), und ist dieselbe
// Regel, nach der auch Eintragsbezeichner aus Titeln entstehen. Eine Regel
// für beides ist leichter zu merken als zwei.
//
// # Wie goldmark das benutzt
//
// goldmark erwartet hier ein Interface (parser.IDs) mit zwei Methoden:
// Generate und Put. In Go muss man ein Interface nicht ausdrücklich
// "implementieren" — es reicht, dass der Typ Methoden mit genau diesen Namen
// und Signaturen hat. Der Compiler prüft das an der Stelle, wo &anchorIDs{…}
// an parser.WithIDs übergeben wird.
type anchorIDs struct {
assigned map[string]bool
}
// Generate bildet aus dem Text einer Überschrift eine id. Kommt dieselbe
// Überschrift zweimal vor, bekommt die zweite ein "-1" angehängt, die dritte
// ein "-2" — ids müssen auf einer Seite eindeutig sein.
func (a *anchorIDs) Generate(text []byte, _ ast.NodeKind) []byte {
base := store.Slugify(string(text))
if base == "" {
// Eine Überschrift nur aus Sonderzeichen ("## ???") — dann eben
// ein neutraler Name, eindeutig gemacht wie jeder andere.
base = "abschnitt"
}
id := base
for number := 1; a.assigned[id]; number++ {
id = fmt.Sprintf("%s-%d", base, number)
}
a.assigned[id] = true
return []byte(id)
}
// Put merkt sich eine von Hand vergebene id, damit Generate sie nicht noch
// einmal erzeugt. (Von Hand vergebene ids nutzen die Hilfeseiten derzeit
// nicht, goldmark verlangt die Methode aber.)
func (a *anchorIDs) Put(id []byte) {
a.assigned[string(id)] = true
}