munin/internal/store/links.go

230 lines
7 KiB
Go

package store
import (
"regexp"
"sort"
"strings"
)
// linkPattern findet Verweise der Form [[slug]] im Inhalt eines Eintrags.
//
// Die Schreibweise stammt aus Wiki-Systemen und ist Menschen wie KI-Werkzeugen
// vertraut. Sie hat einen praktischen Vorzug gegenüber Markdown-Links: Sie
// enthält nur den Bezeichner, keine URL — die Verweise bleiben also gültig,
// wenn sich Adressen ändern, und sie sind ohne Kontextwissen erkennbar.
//
// Erlaubt ist derselbe Zeichenvorrat wie bei Slugs (siehe ValidSlug). Damit wird
// aus "[[siehe hier]]" kein Verweis — das ist gewollt, sonst würde jede eckige
// Klammer im Text zu einem Falschtreffer.
var linkPattern = regexp.MustCompile(`\[\[([a-z0-9][a-z0-9_-]{0,63})\]\]`)
// ExtractLinks liest alle [[slug]]-Verweise aus einem Text.
// Das Ergebnis ist sortiert und ohne Dubletten — ein zweiter Verweis auf dasselbe
// Ziel ist keine zusätzliche Information.
//
// Was in Code steht, zählt nicht (siehe verweiseAusserhalbVonCode).
func ExtractLinks(content string) []string {
treffer := verweiseAusserhalbVonCode(content)
if len(treffer) == 0 {
return nil
}
gesehen := make(map[string]bool, len(treffer))
var ziele []string
for _, t := range treffer {
ziel := content[t[2]:t[3]]
if gesehen[ziel] {
continue
}
gesehen[ziel] = true
ziele = append(ziele, ziel)
}
sort.Strings(ziele)
return ziele
}
// ReplaceLinks ersetzt jeden [[slug]]-Verweis durch das Ergebnis von fn.
//
// Wird beim Rendern verwendet, um aus den Verweisen echte Links zu machen. Die
// Ersetzungsfunktion bekommt den Slug und liefert den Ersatztext. Was in Code
// steht, bleibt unverändert (siehe verweiseAusserhalbVonCode).
func ReplaceLinks(content string, fn func(slug string) string) string {
treffer := verweiseAusserhalbVonCode(content)
if len(treffer) == 0 {
return content
}
var b strings.Builder
b.Grow(len(content))
bisher := 0
for _, t := range treffer {
b.WriteString(content[bisher:t[0]])
b.WriteString(fn(content[t[2]:t[3]]))
bisher = t[1]
}
b.WriteString(content[bisher:])
return b.String()
}
// verweiseAusserhalbVonCode findet alle [[slug]]-Verweise, die NICHT in Code
// stehen. Jeder Treffer ist wie bei regexp.FindAllStringSubmatchIndex
// aufgebaut: [Anfang, Ende, Anfang des Slugs, Ende des Slugs].
//
// # Warum?
//
// Technische Einträge erklären oft, wie man verweist, und schreiben dafür
// ein Beispiel in Code: `[[bezeichner]]`. Früher wurde auch das als Verweis
// gezählt. Er erschien dann unter „Verweise ins Leere", und beim Anzeigen
// stand mitten im Code ein Markdown-Link statt des Beispiels.
//
// Als Code gilt, was Markdown als Code darstellt: Codeblöcke zwischen ```
// oder ~~~ und Code im Fließtext zwischen Backticks.
func verweiseAusserhalbVonCode(content string) [][]int {
alle := linkPattern.FindAllStringSubmatchIndex(content, -1)
if len(alle) == 0 {
return nil
}
code := codeBereiche(content)
if len(code) == 0 {
return alle
}
var draussen [][]int
for _, t := range alle {
if !inBereich(t[0], code) {
draussen = append(draussen, t)
}
}
return draussen
}
// inBereich sagt, ob die Stelle pos in einem der Bereiche liegt.
func inBereich(pos int, bereiche [][2]int) bool {
for _, b := range bereiche {
if pos >= b[0] && pos < b[1] {
return true
}
}
return false
}
// codeBereiche liefert die Stellen eines Markdown-Texts, die Code sind, als
// [Anfang, Ende) in Bytes.
//
// Das ist bewusst kein vollständiger Markdown-Parser, sondern deckt die zwei
// Formen ab, in denen Code in Einträgen tatsächlich vorkommt:
//
// - Codeblöcke: Eine Zeile beginnt (nach höchstens drei Leerzeichen) mit
// mindestens drei ``` oder ~~~. Der Block endet an einer Zeile mit
// mindestens ebenso vielen gleichen Zeichen, sonst am Textende — so
// behandelt es auch Markdown.
// - Code im Fließtext: Eine Folge von Backticks bis zur nächsten Folge mit
// genau derselben Länge. Fehlt das Gegenstück, ist es kein Code.
//
// Eingerückte Codeblöcke (vier Leerzeichen) fehlen absichtlich: Sie sind von
// eingerückten Listenzeilen nicht sicher zu unterscheiden, und ein
// übersehener Verweis wäre schlimmer als ein überflüssiger.
func codeBereiche(content string) [][2]int {
var bereiche [][2]int
// ── 1. Codeblöcke, Zeile für Zeile ───────────────────────────────────
var fliesstext [][2]int // was außerhalb von Codeblöcken liegt
blockAnfang := -1
var zaun string // z.B. "```" oder "~~~~" — öffnender Zaun des Blocks
zeilenAnfang := 0
textAnfang := 0
for zeilenAnfang < len(content) {
zeilenEnde := strings.IndexByte(content[zeilenAnfang:], '\n')
if zeilenEnde < 0 {
zeilenEnde = len(content)
} else {
zeilenEnde += zeilenAnfang + 1 // samt Zeilenumbruch
}
zeile := content[zeilenAnfang:zeilenEnde]
if blockAnfang < 0 {
if z := codeZaun(zeile); z != "" {
fliesstext = append(fliesstext, [2]int{textAnfang, zeilenAnfang})
blockAnfang, zaun = zeilenAnfang, z
}
} else if z := codeZaun(zeile); z != "" && z[0] == zaun[0] && len(z) >= len(zaun) &&
strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(zeile), z[:1])) == "" {
// Schließender Zaun: nur Zaunzeichen, nichts dahinter.
bereiche = append(bereiche, [2]int{blockAnfang, zeilenEnde})
blockAnfang = -1
textAnfang = zeilenEnde
}
zeilenAnfang = zeilenEnde
}
if blockAnfang >= 0 {
bereiche = append(bereiche, [2]int{blockAnfang, len(content)})
} else {
fliesstext = append(fliesstext, [2]int{textAnfang, len(content)})
}
// ── 2. Code im Fließtext, nur außerhalb der Codeblöcke ───────────────
for _, f := range fliesstext {
text := content[f[0]:f[1]]
for i := 0; i < len(text); {
if text[i] != '`' {
i++
continue
}
laenge := laufLaenge(text, i, '`')
ende := schliessendeBackticks(text, i+laenge, laenge)
if ende < 0 {
i += laenge // kein Gegenstück: kein Code
continue
}
bereiche = append(bereiche, [2]int{f[0] + i, f[0] + ende + laenge})
i = ende + laenge
}
}
return bereiche
}
// codeZaun liefert den Zaun (z.B. "```"), wenn die Zeile einen Codeblock
// öffnet oder schließt, sonst "".
func codeZaun(zeile string) string {
eingerueckt := len(zeile) - len(strings.TrimLeft(zeile, " "))
if eingerueckt > 3 {
return ""
}
rest := zeile[eingerueckt:]
if rest == "" || (rest[0] != '`' && rest[0] != '~') {
return ""
}
n := laufLaenge(rest, 0, rest[0])
if n < 3 {
return ""
}
return rest[:n]
}
// laufLaenge zählt, wie oft das Zeichen c ab Stelle i hintereinander steht.
func laufLaenge(text string, i int, c byte) int {
n := 0
for i+n < len(text) && text[i+n] == c {
n++
}
return n
}
// schliessendeBackticks sucht ab Stelle ab eine Folge von genau laenge
// Backticks und gibt ihre Stelle zurück, sonst -1.
func schliessendeBackticks(text string, ab, laenge int) int {
for i := ab; i < len(text); {
if text[i] != '`' {
i++
continue
}
n := laufLaenge(text, i, '`')
if n == laenge {
return i
}
i += n
}
return -1
}