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}