230 lines
6.8 KiB
Go
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
|
|
}
|