Umsetzung der Ausbaustufen 2.1, 3.6, 3.8, 3.9, 4.1, 4.2, 4.4, 4.6, 5.1–5.4 (Details in AUSBAUSTUFEN.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
148 lines
6.2 KiB
Go
148 lines
6.2 KiB
Go
// 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/hilfe).
|
||
//
|
||
// # 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"
|
||
)
|
||
|
||
// neuerRenderer 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 neuerRenderer() also auch ganz ohne
|
||
// Zusatzoptionen aufrufen.
|
||
func neuerRenderer(zusatz ...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(zusatz...),
|
||
)
|
||
}
|
||
|
||
// 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 (
|
||
// ohneAnker 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.
|
||
ohneAnker = neuerRenderer()
|
||
|
||
// mitAnker 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.
|
||
mitAnker = neuerRenderer(parser.WithAutoHeadingID())
|
||
)
|
||
|
||
// Render wandelt Markdown in HTML, ohne Überschrift-Anker. Für Einträge.
|
||
func Render(quelle string) (string, error) {
|
||
return umwandeln(ohneAnker, quelle)
|
||
}
|
||
|
||
// RenderMitAnkern wandelt Markdown in HTML und gibt jeder Überschrift eine id.
|
||
// Für die Hilfe.
|
||
func RenderMitAnkern(quelle 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).
|
||
kontext := parser.NewContext(parser.WithIDs(&ankerIDs{vergeben: map[string]bool{}}))
|
||
return umwandeln(mitAnker, quelle, parser.WithContext(kontext))
|
||
}
|
||
|
||
// umwandeln ist der gemeinsame Teil beider Funktionen.
|
||
func umwandeln(renderer goldmark.Markdown, quelle string, optionen ...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 puffer bytes.Buffer
|
||
if err := renderer.Convert([]byte(quelle), &puffer, optionen...); err != nil {
|
||
return "", fmt.Errorf("Markdown konnte nicht dargestellt werden: %w", err)
|
||
}
|
||
return puffer.String(), nil
|
||
}
|
||
|
||
// ankerIDs 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 &ankerIDs{…}
|
||
// an parser.WithIDs übergeben wird.
|
||
type ankerIDs struct {
|
||
vergeben 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 *ankerIDs) Generate(text []byte, _ ast.NodeKind) []byte {
|
||
basis := store.Slugify(string(text))
|
||
if basis == "" {
|
||
// Eine Überschrift nur aus Sonderzeichen ("## ???") — dann eben
|
||
// ein neutraler Name, eindeutig gemacht wie jeder andere.
|
||
basis = "abschnitt"
|
||
}
|
||
|
||
id := basis
|
||
for nummer := 1; a.vergeben[id]; nummer++ {
|
||
id = fmt.Sprintf("%s-%d", basis, nummer)
|
||
}
|
||
a.vergeben[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 *ankerIDs) Put(id []byte) {
|
||
a.vergeben[string(id)] = true
|
||
}
|