munin/internal/markdown/markdown.go
Thomas Adamek 466f1e40af Workspaces, Änderungshistorie, Papierkorb, Nutzungsübersicht, Hilfe
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>
2026-09-25 07:44:52 +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/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
}