munin/internal/store/links.go
2026-10-19 10:13:56 +02:00

230 lines
6.8 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 refsOutsideCode).
func ExtractLinks(content string) []string {
hits := refsOutsideCode(content)
if len(hits) == 0 {
return nil
}
seen := make(map[string]bool, len(hits))
var targets []string
for _, t := range hits {
target := content[t[2]:t[3]]
if seen[target] {
continue
}
seen[target] = true
targets = append(targets, target)
}
sort.Strings(targets)
return targets
}
// 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 refsOutsideCode).
func ReplaceLinks(content string, fn func(slug string) string) string {
hits := refsOutsideCode(content)
if len(hits) == 0 {
return content
}
var b strings.Builder
b.Grow(len(content))
sofar := 0
for _, t := range hits {
b.WriteString(content[sofar:t[0]])
b.WriteString(fn(content[t[2]:t[3]]))
sofar = t[1]
}
b.WriteString(content[sofar:])
return b.String()
}
// refsOutsideCode 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 refsOutsideCode(content string) [][]int {
all := linkPattern.FindAllStringSubmatchIndex(content, -1)
if len(all) == 0 {
return nil
}
code := codeRanges(content)
if len(code) == 0 {
return all
}
var outside [][]int
for _, t := range all {
if !inRange(t[0], code) {
outside = append(outside, t)
}
}
return outside
}
// inRange sagt, ob die Stelle pos in einem der Bereiche liegt.
func inRange(pos int, ranges [][2]int) bool {
for _, b := range ranges {
if pos >= b[0] && pos < b[1] {
return true
}
}
return false
}
// codeRanges 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 codeRanges(content string) [][2]int {
var ranges [][2]int
// ── 1. Codeblöcke, Zeile für Zeile ───────────────────────────────────
var prose [][2]int // was außerhalb von Codeblöcken liegt
blockStart := -1
var fence string // z.B. "```" oder "~~~~" — öffnender Zaun des Blocks
lineStart := 0
textStart := 0
for lineStart < len(content) {
lineEnd := strings.IndexByte(content[lineStart:], '\n')
if lineEnd < 0 {
lineEnd = len(content)
} else {
lineEnd += lineStart + 1 // samt Zeilenumbruch
}
line := content[lineStart:lineEnd]
if blockStart < 0 {
if z := codeFence(line); z != "" {
prose = append(prose, [2]int{textStart, lineStart})
blockStart, fence = lineStart, z
}
} else if z := codeFence(line); z != "" && z[0] == fence[0] && len(z) >= len(fence) &&
strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), z[:1])) == "" {
// Schließender Zaun: nur Zaunzeichen, nichts dahinter.
ranges = append(ranges, [2]int{blockStart, lineEnd})
blockStart = -1
textStart = lineEnd
}
lineStart = lineEnd
}
if blockStart >= 0 {
ranges = append(ranges, [2]int{blockStart, len(content)})
} else {
prose = append(prose, [2]int{textStart, len(content)})
}
// ── 2. Code im Fließtext, nur außerhalb der Codeblöcke ───────────────
for _, f := range prose {
text := content[f[0]:f[1]]
for i := 0; i < len(text); {
if text[i] != '`' {
i++
continue
}
length := runLength(text, i, '`')
end := closingBackticks(text, i+length, length)
if end < 0 {
i += length // kein Gegenstück: kein Code
continue
}
ranges = append(ranges, [2]int{f[0] + i, f[0] + end + length})
i = end + length
}
}
return ranges
}
// codeFence liefert den Zaun (z.B. "```"), wenn die Zeile einen Codeblock
// öffnet oder schließt, sonst "".
func codeFence(line string) string {
indented := len(line) - len(strings.TrimLeft(line, " "))
if indented > 3 {
return ""
}
rest := line[indented:]
if rest == "" || (rest[0] != '`' && rest[0] != '~') {
return ""
}
n := runLength(rest, 0, rest[0])
if n < 3 {
return ""
}
return rest[:n]
}
// runLength zählt, wie oft das Zeichen c ab Stelle i hintereinander steht.
func runLength(text string, i int, c byte) int {
n := 0
for i+n < len(text) && text[i+n] == c {
n++
}
return n
}
// closingBackticks sucht ab Stelle from eine Folge von genau length
// Backticks und gibt ihre Stelle zurück, sonst -1.
func closingBackticks(text string, from, length int) int {
for i := from; i < len(text); {
if text[i] != '`' {
i++
continue
}
n := runLength(text, i, '`')
if n == length {
return i
}
i += n
}
return -1
}