webui_helpers.go

  1package web
  2
  3import (
  4	"bytes"
  5	"context"
  6	"html/template"
  7	"strings"
  8	"sync"
  9
 10	"github.com/charmbracelet/soft-serve/git"
 11	"github.com/charmbracelet/soft-serve/pkg/backend"
 12	"github.com/charmbracelet/soft-serve/pkg/proto"
 13	"github.com/microcosm-cc/bluemonday"
 14	"github.com/yuin/goldmark"
 15	extension "github.com/yuin/goldmark/extension"
 16	"github.com/yuin/goldmark/parser"
 17	goldmarkhtml "github.com/yuin/goldmark/renderer/html"
 18	"github.com/yuin/goldmark/util"
 19)
 20
 21var (
 22	sanitizerPolicy     *bluemonday.Policy
 23	sanitizerPolicyOnce sync.Once
 24)
 25
 26// getSanitizerPolicy returns a cached sanitizer policy.
 27// The policy is safe for concurrent use after initialization.
 28// Note: Tests should not use t.Parallel() with functions that call renderMarkdown
 29// until we verify bluemonday.Policy initialization is thread-safe during sync.Once.
 30func getSanitizerPolicy() *bluemonday.Policy {
 31	sanitizerPolicyOnce.Do(func() {
 32		policy := bluemonday.NewPolicy()
 33
 34		// Allowed tags
 35		policy.AllowElements("p", "h1", "h2", "h3", "h4", "h5", "h6",
 36			"ul", "ol", "li", "pre", "code", "blockquote",
 37			"strong", "em", "del", "br", "hr",
 38			"table", "thead", "tbody", "tr", "th", "td",
 39			"center", "a", "img", "details", "summary")
 40
 41		// Allow id attributes on headings (generated by AutoHeadingID for anchor links)
 42		policy.AllowAttrs("id").OnElements("h1", "h2", "h3", "h4", "h5", "h6")
 43
 44		// Links: only https scheme and relative URLs, add nofollow/noreferrer
 45		policy.AllowAttrs("href").OnElements("a")
 46		policy.RequireNoFollowOnLinks(true)
 47		policy.RequireNoReferrerOnLinks(true)
 48
 49		// Images: only https scheme or repo-relative paths, allow descriptive attrs
 50		policy.AllowAttrs("src", "alt", "title", "width", "height").OnElements("img")
 51
 52		// URL schemes: only HTTPS for external URLs, relative URLs for repo paths
 53		policy.AllowURLSchemes("https")
 54		policy.AllowRelativeURLs(true)
 55
 56		// Table alignment attributes
 57		policy.AllowAttrs("align").Matching(bluemonday.SpaceSeparatedTokens).OnElements("th", "td")
 58
 59		sanitizerPolicy = policy
 60	})
 61	return sanitizerPolicy
 62}
 63
 64// renderMarkdown converts markdown content to sanitized HTML.
 65// If ctx is provided, relative URLs will be rewritten to point to repository files.
 66func renderMarkdown(content []byte, ctx *ReadmeContext) (template.HTML, error) {
 67	var buf bytes.Buffer
 68	
 69	mdOpts := []goldmark.Option{
 70		goldmark.WithExtensions(extension.GFM),
 71		goldmark.WithParserOptions(
 72			parser.WithAutoHeadingID(),
 73		),
 74		goldmark.WithRendererOptions(
 75			goldmarkhtml.WithUnsafe(),
 76		),
 77	}
 78	
 79	// Add URL rewriter if context is provided
 80	if ctx != nil {
 81		rewriter := newURLRewriter(*ctx)
 82		mdOpts = append(mdOpts, goldmark.WithParserOptions(
 83			parser.WithASTTransformers(util.Prioritized(rewriter, 500)),
 84		))
 85	}
 86	
 87	md := goldmark.New(mdOpts...)
 88
 89	if err := md.Convert(content, &buf); err != nil {
 90		return "", err
 91	}
 92
 93	// Use cached sanitizer policy
 94	sanitized := getSanitizerPolicy().SanitizeBytes(buf.Bytes())
 95
 96	return template.HTML(sanitized), nil
 97}
 98
 99// getServerReadme loads and renders the README from the .soft-serve repository.
100// Returns both the raw markdown content and the rendered HTML.
101func getServerReadme(ctx context.Context, be *backend.Backend) (raw string, html template.HTML, err error) {
102	repos, err := be.Repositories(ctx)
103	if err != nil {
104		return "", "", err
105	}
106
107	for _, r := range repos {
108		if r.Name() == ".soft-serve" {
109			readme, _, err := backend.Readme(r, nil)
110			if err != nil {
111				return "", "", err
112			}
113			if readme != "" {
114				html, err := renderMarkdown([]byte(readme), nil)
115				if err != nil {
116					return "", "", err
117				}
118				return readme, html, nil
119			}
120		}
121	}
122
123	return "", "", nil
124}
125
126// openRepository opens a git repository.
127func openRepository(repo proto.Repository) (*git.Repository, error) {
128	return repo.Open()
129}
130
131// getDefaultBranch returns the default branch name, or empty string if none exists.
132func getDefaultBranch(gr *git.Repository) string {
133	head, err := gr.HEAD()
134	if err != nil || head == nil {
135		return ""
136	}
137	return head.Name().Short()
138}
139
140// truncateText truncates text to maxLength characters, respecting word boundaries.
141// If truncated, appends "...". Returns empty string if text is empty or maxLength <= 0.
142func truncateText(text string, maxLength int) string {
143	text = strings.TrimSpace(text)
144	if text == "" || maxLength <= 0 {
145		return ""
146	}
147
148	if len(text) <= maxLength {
149		return text
150	}
151
152	// Find last space before maxLength
153	truncated := text[:maxLength]
154	if lastSpace := strings.LastIndex(truncated, " "); lastSpace > 0 {
155		truncated = truncated[:lastSpace]
156	}
157
158	return truncated + "..."
159}
160
161// extractPlainTextFromMarkdown converts markdown to plain text and truncates to maxLength.
162// Strips markdown formatting to produce a clean description suitable for meta tags.
163func extractPlainTextFromMarkdown(markdown string, maxLength int) string {
164	markdown = strings.TrimSpace(markdown)
165	if markdown == "" {
166		return ""
167	}
168
169	// Simple markdown stripping - remove common formatting
170	text := markdown
171
172	// Remove headers
173	text = strings.ReplaceAll(text, "# ", "")
174	text = strings.ReplaceAll(text, "## ", "")
175	text = strings.ReplaceAll(text, "### ", "")
176	text = strings.ReplaceAll(text, "#### ", "")
177	text = strings.ReplaceAll(text, "##### ", "")
178	text = strings.ReplaceAll(text, "###### ", "")
179
180	// Remove bold/italic markers
181	text = strings.ReplaceAll(text, "**", "")
182	text = strings.ReplaceAll(text, "__", "")
183	text = strings.ReplaceAll(text, "*", "")
184	text = strings.ReplaceAll(text, "_", "")
185
186	// Remove code blocks and inline code
187	text = strings.ReplaceAll(text, "`", "")
188
189	// Remove links but keep text: [text](url) -> text
190	for strings.Contains(text, "[") && strings.Contains(text, "](") {
191		start := strings.Index(text, "[")
192		mid := strings.Index(text[start:], "](")
193		if mid == -1 {
194			break
195		}
196		end := strings.Index(text[start+mid+2:], ")")
197		if end == -1 {
198			break
199		}
200		linkText := text[start+1 : start+mid]
201		text = text[:start] + linkText + text[start+mid+2+end+1:]
202	}
203
204	// Replace multiple spaces/newlines with single space
205	text = strings.Join(strings.Fields(text), " ")
206
207	return truncateText(text, maxLength)
208}
209
210// getRepoDescriptionOrFallback returns the repository description if available,
211// otherwise returns the fallback text. Result is truncated to 200 characters.
212func getRepoDescriptionOrFallback(repo proto.Repository, fallback string) string {
213	desc := strings.TrimSpace(repo.Description())
214	if desc == "" {
215		desc = fallback
216	}
217	return truncateText(desc, 200)
218}