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