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 }