// Package entry enthält die Fachlogik rund um einen Wissenseintrag: die // Darstellung als HTML, Textausschnitte für Suchergebnisse und die Auflösung // der [[slug]]-Verweise. // // Bewusst getrennt von internal/store: Dort geht es darum, wie ein Eintrag // gespeichert wird, hier darum, wie er aussieht. Beides ändert sich aus // unterschiedlichen Gründen. package entry import ( "bytes" "fmt" "html" "strings" "github.com/yuin/goldmark" "munin/internal/store" ) // RenderHTML wandelt den Markdown-Inhalt eines Eintrags in HTML. // // # Sicherheitshinweis // // goldmark lässt in der Standardeinstellung eingebettetes Roh-HTML NICHT durch — // aus im Markdown wird ein Kommentar, kein Skript. // Genau darauf verlässt sich die Weboberfläche, wenn sie das Ergebnis ohne // weiteres Escaping einsetzt. // // Diese Einstellung darf deshalb nie auf "unsafe" gestellt werden. Als zweite // Verteidigungslinie steht die Content-Security-Policy (siehe internal/web). func RenderHTML(markdown string, linkBase string) (string, error) { // Verweise vor dem Rendern in Markdown-Links umschreiben. Danach wäre es zu // spät: goldmark hätte die eckigen Klammern dann schon als Text behandelt. vorbereitet := store.ReplaceLinks(markdown, func(slug string) string { return fmt.Sprintf("[%s](%s%s)", slug, linkBase, slug) }) var puffer bytes.Buffer if err := goldmark.Convert([]byte(vorbereitet), &puffer); err != nil { return "", fmt.Errorf("Inhalt konnte nicht dargestellt werden: %w", err) } return puffer.String(), nil } // RenderPlain gibt den Inhalt als reinen Text zurück — Verweise werden auf ihren // Slug reduziert. Gedacht für Textausschnitte, wo Markdown-Zeichen nur stören. func RenderPlain(markdown string) string { return store.ReplaceLinks(markdown, func(slug string) string { return slug }) } // snippetRadius bestimmt, wie viel Text vor und nach einem Treffer im Ausschnitt steht. const snippetRadius = 120 // Snippet gibt einen Textausschnitt um den ersten Treffer des Suchbegriffs zurück. // // Der Ausschnitt ist bewusst kurz: Er dient dazu, einen Treffer einzuordnen — // bei einer KI-Anfrage entscheidet er darüber, ob der ganze Eintrag nachgeladen // wird. Wer den vollen Inhalt will, holt ihn gezielt. func Snippet(content, query string) string { text := RenderPlain(content) idx := strings.Index(strings.ToLower(text), strings.ToLower(query)) if idx == -1 { // Kein wörtlicher Treffer (z.B. weil der Fund über die Präfix-Suche kam) — // dann der Anfang des Texts als Vorschau. return kuerzen(text, snippetRadius+snippetRadius) } start := max(idx-snippetRadius, 0) ende := min(idx+len(query)+snippetRadius, len(text)) ausschnitt := strings.TrimSpace(text[start:ende]) if start > 0 { ausschnitt = "…" + ausschnitt } if ende < len(text) { ausschnitt += "…" } return ausschnitt } // kuerzen schneidet einen Text auf höchstens n Zeichen. func kuerzen(text string, n int) string { text = strings.TrimSpace(text) if len(text) <= n { return text } return strings.TrimSpace(text[:n]) + "…" } // HighlightSnippet baut einen Textausschnitt mit hervorgehobenem Treffer als // fertiges HTML. // // Wichtig: Erst wird der Text escaped, dann werden die -Auszeichnungen // eingesetzt. Andersherum könnte ein Eintragsinhalt eigenes HTML einschleusen. func HighlightSnippet(content, query string) string { ausschnitt := Snippet(content, query) sicher := html.EscapeString(ausschnitt) if query == "" { return sicher } // Im escapten Text nach dem (ebenfalls escapten) Suchbegriff suchen, damit // die Positionen zusammenpassen. nadel := html.EscapeString(query) idx := strings.Index(strings.ToLower(sicher), strings.ToLower(nadel)) if idx == -1 { return sicher } return sicher[:idx] + "" + sicher[idx:idx+len(nadel)] + "" + sicher[idx+len(nadel):] } // TitleFromContent rät einen Titel aus dem Inhalt — die erste Überschrift oder // die erste Zeile. Wird verwendet, wenn ein Agent keinen Titel mitgeschickt hat. func TitleFromContent(content string) string { for _, zeile := range strings.Split(content, "\n") { zeile = strings.TrimSpace(zeile) if zeile == "" { continue } zeile = strings.TrimLeft(zeile, "#") zeile = strings.TrimSpace(zeile) if zeile != "" { return kuerzen(zeile, 120) } } return "Ohne Titel" }