1package web
  2
  3import (
  4	"bytes"
  5	"context"
  6	"html/template"
  7	"strings"
  8
  9	"github.com/charmbracelet/soft-serve/git"
 10	"github.com/charmbracelet/soft-serve/pkg/backend"
 11	"github.com/charmbracelet/soft-serve/pkg/proto"
 12	"github.com/microcosm-cc/bluemonday"
 13	"github.com/yuin/goldmark"
 14	extension "github.com/yuin/goldmark/extension"
 15	"github.com/yuin/goldmark/parser"
 16	goldmarkhtml "github.com/yuin/goldmark/renderer/html"
 17)
 18
 19// renderMarkdown converts markdown content to sanitized HTML.
 20func renderMarkdown(content []byte) (template.HTML, error) {
 21	var buf bytes.Buffer
 22	md := goldmark.New(
 23		goldmark.WithExtensions(extension.GFM),
 24		goldmark.WithParserOptions(
 25			parser.WithAutoHeadingID(),
 26		),
 27		goldmark.WithRendererOptions(
 28			goldmarkhtml.WithUnsafe(),
 29		),
 30	)
 31
 32	if err := md.Convert(content, &buf); err != nil {
 33		return "", err
 34	}
 35
 36	policy := bluemonday.UGCPolicy()
 37	policy.AllowStyling()
 38	policy.RequireNoFollowOnLinks(false)
 39	policy.AllowElements("center")
 40	sanitized := policy.SanitizeBytes(buf.Bytes())
 41
 42	return template.HTML(sanitized), nil
 43}
 44
 45// getServerReadme loads and renders the README from the .soft-serve repository.
 46// Returns both the raw markdown content and the rendered HTML.
 47func getServerReadme(ctx context.Context, be *backend.Backend) (raw string, html template.HTML, err error) {
 48	repos, err := be.Repositories(ctx)
 49	if err != nil {
 50		return "", "", err
 51	}
 52
 53	for _, r := range repos {
 54		if r.Name() == ".soft-serve" {
 55			readme, _, err := backend.Readme(r, nil)
 56			if err != nil {
 57				return "", "", err
 58			}
 59			if readme != "" {
 60				html, err := renderMarkdown([]byte(readme))
 61				if err != nil {
 62					return "", "", err
 63				}
 64				return readme, html, nil
 65			}
 66		}
 67	}
 68
 69	return "", "", nil
 70}
 71
 72// openRepository opens a git repository.
 73func openRepository(repo proto.Repository) (*git.Repository, error) {
 74	return repo.Open()
 75}
 76
 77// getDefaultBranch returns the default branch name, or empty string if none exists.
 78func getDefaultBranch(gr *git.Repository) string {
 79	head, err := gr.HEAD()
 80	if err != nil || head == nil {
 81		return ""
 82	}
 83	return head.Name().Short()
 84}
 85
 86// truncateText truncates text to maxLength characters, respecting word boundaries.
 87// If truncated, appends "...". Returns empty string if text is empty or maxLength <= 0.
 88func truncateText(text string, maxLength int) string {
 89	text = strings.TrimSpace(text)
 90	if text == "" || maxLength <= 0 {
 91		return ""
 92	}
 93
 94	if len(text) <= maxLength {
 95		return text
 96	}
 97
 98	// Find last space before maxLength
 99	truncated := text[:maxLength]
100	if lastSpace := strings.LastIndex(truncated, " "); lastSpace > 0 {
101		truncated = truncated[:lastSpace]
102	}
103
104	return truncated + "..."
105}
106
107// extractPlainTextFromMarkdown converts markdown to plain text and truncates to maxLength.
108// Strips markdown formatting to produce a clean description suitable for meta tags.
109func extractPlainTextFromMarkdown(markdown string, maxLength int) string {
110	markdown = strings.TrimSpace(markdown)
111	if markdown == "" {
112		return ""
113	}
114
115	// Simple markdown stripping - remove common formatting
116	text := markdown
117
118	// Remove headers
119	text = strings.ReplaceAll(text, "# ", "")
120	text = strings.ReplaceAll(text, "## ", "")
121	text = strings.ReplaceAll(text, "### ", "")
122	text = strings.ReplaceAll(text, "#### ", "")
123	text = strings.ReplaceAll(text, "##### ", "")
124	text = strings.ReplaceAll(text, "###### ", "")
125
126	// Remove bold/italic markers
127	text = strings.ReplaceAll(text, "**", "")
128	text = strings.ReplaceAll(text, "__", "")
129	text = strings.ReplaceAll(text, "*", "")
130	text = strings.ReplaceAll(text, "_", "")
131
132	// Remove code blocks and inline code
133	text = strings.ReplaceAll(text, "`", "")
134
135	// Remove links but keep text: [text](url) -> text
136	for strings.Contains(text, "[") && strings.Contains(text, "](") {
137		start := strings.Index(text, "[")
138		mid := strings.Index(text[start:], "](")
139		if mid == -1 {
140			break
141		}
142		end := strings.Index(text[start+mid+2:], ")")
143		if end == -1 {
144			break
145		}
146		linkText := text[start+1 : start+mid]
147		text = text[:start] + linkText + text[start+mid+2+end+1:]
148	}
149
150	// Replace multiple spaces/newlines with single space
151	text = strings.Join(strings.Fields(text), " ")
152
153	return truncateText(text, maxLength)
154}
155
156// getRepoDescriptionOrFallback returns the repository description if available,
157// otherwise returns the fallback text. Result is truncated to 200 characters.
158func getRepoDescriptionOrFallback(repo proto.Repository, fallback string) string {
159	desc := strings.TrimSpace(repo.Description())
160	if desc == "" {
161		desc = fallback
162	}
163	return truncateText(desc, 200)
164}