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