// 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 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 }