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 }