feat(web): add meta descriptions to pages

Amolith and Crush created

Adds Description field to BaseData and populates it across
all web UI pages (home, about, blob, tree, branches, commits,
tags). Descriptions are derived from:
- Repository descriptions (truncated to 200 chars)
- Server README content (converted to plain text)
- Context-appropriate fallbacks

New helper functions in webui_helpers.go:
- extractPlainTextFromMarkdown: strips markdown formatting
- truncateText: truncates text at word boundaries
- getRepoDescriptionOrFallback: repo desc or fallback

Implements: bug-7b97785
Co-authored-by: Crush <crush@charm.land>

Change summary

pkg/web/webui_about.go    | 15 ++++-
pkg/web/webui_blob.go     |  9 ++-
pkg/web/webui_branches.go |  9 ++-
pkg/web/webui_commits.go  |  9 ++-
pkg/web/webui_helpers.go  | 96 ++++++++++++++++++++++++++++++++++++++--
pkg/web/webui_home.go     | 16 +++++-
pkg/web/webui_tags.go     |  9 ++-
pkg/web/webui_tree.go     | 21 +++++++-
8 files changed, 156 insertions(+), 28 deletions(-)

Detailed changes

pkg/web/webui_about.go 🔗

@@ -28,18 +28,25 @@ func about(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	readmeHTML, err := getServerReadme(ctx, be)
+	readmeRaw, readmeHTML, err := getServerReadme(ctx, be)
 	if err != nil {
 		logger.Debug("failed to get server README", "err", err)
 		renderInternalServerError(w, r)
 		return
 	}
 
+	// Generate description from README or fallback
+	description := extractPlainTextFromMarkdown(readmeRaw, 200)
+	if description == "" {
+		description = "About " + cfg.Name
+	}
+
 	data := AboutData{
 		BaseData: BaseData{
-			ServerName: cfg.Name,
-			ActiveTab:  "about",
-			Title:      "About | " + cfg.Name,
+			ServerName:  cfg.Name,
+			ActiveTab:   "about",
+			Title:       "About | " + cfg.Name,
+			Description: description,
 		},
 		ReadmeHTML: readmeHTML,
 	}

pkg/web/webui_blob.go 🔗

@@ -105,12 +105,15 @@ func repoBlob(w http.ResponseWriter, r *http.Request) {
 	}
 	fileName := filepath.Base(path)
 
+	description := getRepoDescriptionOrFallback(repo, "View "+fileName+" in "+repoDisplayName)
+
 	data := BlobData{
 		RepoBaseData: RepoBaseData{
 			BaseData: BaseData{
-				ServerName: cfg.Name,
-				ActiveTab:  "tree",
-				Title:      fileName + " | " + repoDisplayName,
+				ServerName:  cfg.Name,
+				ActiveTab:   "tree",
+				Title:       fileName + " | " + repoDisplayName,
+				Description: description,
 			},
 			Repo:          repo,
 			DefaultBranch: defaultBranch,

pkg/web/webui_branches.go 🔗

@@ -93,12 +93,15 @@ func repoBranches(w http.ResponseWriter, r *http.Request) {
 		repoDisplayName = repo.Name()
 	}
 
+	description := getRepoDescriptionOrFallback(repo, "Branches in "+repoDisplayName)
+
 	data := BranchesData{
 		RepoBaseData: RepoBaseData{
 			BaseData: BaseData{
-				ServerName: cfg.Name,
-				ActiveTab:  "branches",
-				Title:      "Branches | " + repoDisplayName,
+				ServerName:  cfg.Name,
+				ActiveTab:   "branches",
+				Title:       "Branches | " + repoDisplayName,
+				Description: description,
 			},
 			Repo:          repo,
 			DefaultBranch: defaultBranch,

pkg/web/webui_commits.go 🔗

@@ -87,12 +87,15 @@ func repoCommits(w http.ResponseWriter, r *http.Request) {
 		repoDisplayName = repo.Name()
 	}
 
+	description := getRepoDescriptionOrFallback(repo, "Commit log for "+ref+" in "+repoDisplayName)
+
 	data := CommitsData{
 		RepoBaseData: RepoBaseData{
 			BaseData: BaseData{
-				ServerName: cfg.Name,
-				ActiveTab:  "commits",
-				Title:      "Commits in " + ref + " | " + repoDisplayName,
+				ServerName:  cfg.Name,
+				ActiveTab:   "commits",
+				Title:       "Commits in " + ref + " | " + repoDisplayName,
+				Description: description,
 			},
 			Repo:          repo,
 			DefaultBranch: defaultBranch,

pkg/web/webui_helpers.go 🔗

@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"context"
 	"html/template"
+	"strings"
 
 	"github.com/charmbracelet/soft-serve/git"
 	"github.com/charmbracelet/soft-serve/pkg/backend"
@@ -42,25 +43,30 @@ func renderMarkdown(content []byte) (template.HTML, error) {
 }
 
 // getServerReadme loads and renders the README from the .soft-serve repository.
-func getServerReadme(ctx context.Context, be *backend.Backend) (template.HTML, error) {
+// Returns both the raw markdown content and the rendered HTML.
+func getServerReadme(ctx context.Context, be *backend.Backend) (raw string, html template.HTML, err error) {
 	repos, err := be.Repositories(ctx)
 	if err != nil {
-		return "", err
+		return "", "", err
 	}
 
 	for _, r := range repos {
 		if r.Name() == ".soft-serve" {
 			readme, _, err := backend.Readme(r, nil)
 			if err != nil {
-				return "", err
+				return "", "", err
 			}
 			if readme != "" {
-				return renderMarkdown([]byte(readme))
+				html, err := renderMarkdown([]byte(readme))
+				if err != nil {
+					return "", "", err
+				}
+				return readme, html, nil
 			}
 		}
 	}
 
-	return "", nil
+	return "", "", nil
 }
 
 // openRepository opens a git repository.
@@ -76,3 +82,83 @@ func getDefaultBranch(gr *git.Repository) string {
 	}
 	return head.Name().Short()
 }
+
+// truncateText truncates text to maxLength characters, respecting word boundaries.
+// If truncated, appends "...". Returns empty string if text is empty or maxLength <= 0.
+func truncateText(text string, maxLength int) string {
+	text = strings.TrimSpace(text)
+	if text == "" || maxLength <= 0 {
+		return ""
+	}
+
+	if len(text) <= maxLength {
+		return text
+	}
+
+	// Find last space before maxLength
+	truncated := text[:maxLength]
+	if lastSpace := strings.LastIndex(truncated, " "); lastSpace > 0 {
+		truncated = truncated[:lastSpace]
+	}
+
+	return truncated + "..."
+}
+
+// extractPlainTextFromMarkdown converts markdown to plain text and truncates to maxLength.
+// Strips markdown formatting to produce a clean description suitable for meta tags.
+func extractPlainTextFromMarkdown(markdown string, maxLength int) string {
+	markdown = strings.TrimSpace(markdown)
+	if markdown == "" {
+		return ""
+	}
+
+	// Simple markdown stripping - remove common formatting
+	text := markdown
+
+	// Remove headers
+	text = strings.ReplaceAll(text, "# ", "")
+	text = strings.ReplaceAll(text, "## ", "")
+	text = strings.ReplaceAll(text, "### ", "")
+	text = strings.ReplaceAll(text, "#### ", "")
+	text = strings.ReplaceAll(text, "##### ", "")
+	text = strings.ReplaceAll(text, "###### ", "")
+
+	// Remove bold/italic markers
+	text = strings.ReplaceAll(text, "**", "")
+	text = strings.ReplaceAll(text, "__", "")
+	text = strings.ReplaceAll(text, "*", "")
+	text = strings.ReplaceAll(text, "_", "")
+
+	// Remove code blocks and inline code
+	text = strings.ReplaceAll(text, "`", "")
+
+	// Remove links but keep text: [text](url) -> text
+	for strings.Contains(text, "[") && strings.Contains(text, "](") {
+		start := strings.Index(text, "[")
+		mid := strings.Index(text[start:], "](")
+		if mid == -1 {
+			break
+		}
+		end := strings.Index(text[start+mid+2:], ")")
+		if end == -1 {
+			break
+		}
+		linkText := text[start+1 : start+mid]
+		text = text[:start] + linkText + text[start+mid+2+end+1:]
+	}
+
+	// Replace multiple spaces/newlines with single space
+	text = strings.Join(strings.Fields(text), " ")
+
+	return truncateText(text, maxLength)
+}
+
+// getRepoDescriptionOrFallback returns the repository description if available,
+// otherwise returns the fallback text. Result is truncated to 200 characters.
+func getRepoDescriptionOrFallback(repo proto.Repository, fallback string) string {
+	desc := strings.TrimSpace(repo.Description())
+	if desc == "" {
+		desc = fallback
+	}
+	return truncateText(desc, 200)
+}

pkg/web/webui_home.go 🔗

@@ -59,10 +59,11 @@ func home(w http.ResponseWriter, r *http.Request) {
 	}
 
 	var readmeHTML template.HTML
+	var readmeRaw string
 	homeRepos := make([]HomeRepository, 0)
 	items := make([]repoItem, 0)
 
-	readmeHTML, err = getServerReadme(ctx, be)
+	readmeRaw, readmeHTML, err = getServerReadme(ctx, be)
 	if err != nil {
 		logger.Debug("failed to get server README", "err", err)
 	}
@@ -149,11 +150,18 @@ func home(w http.ResponseWriter, r *http.Request) {
 		})
 	}
 
+	// Generate description from README or fallback
+	description := extractPlainTextFromMarkdown(readmeRaw, 200)
+	if description == "" {
+		description = "Git repositories on " + cfg.Name
+	}
+
 	data := HomeData{
 		BaseData: BaseData{
-			ServerName: cfg.Name,
-			ActiveTab:  "repositories",
-			Title:      cfg.Name,
+			ServerName:  cfg.Name,
+			ActiveTab:   "repositories",
+			Title:       cfg.Name,
+			Description: description,
 		},
 		PaginationData: PaginationData{
 			Page:        page,

pkg/web/webui_tags.go 🔗

@@ -113,12 +113,15 @@ func repoTags(w http.ResponseWriter, r *http.Request) {
 		repoDisplayName = repo.Name()
 	}
 
+	description := getRepoDescriptionOrFallback(repo, "Tags in "+repoDisplayName)
+
 	data := TagsData{
 		RepoBaseData: RepoBaseData{
 			BaseData: BaseData{
-				ServerName: cfg.Name,
-				ActiveTab:  "tags",
-				Title:      "Tags | " + repoDisplayName,
+				ServerName:  cfg.Name,
+				ActiveTab:   "tags",
+				Title:       "Tags | " + repoDisplayName,
+				Description: description,
 			},
 			Repo:          repo,
 			DefaultBranch: defaultBranch,

pkg/web/webui_tree.go 🔗

@@ -89,12 +89,27 @@ func repoTree(w http.ResponseWriter, r *http.Request) {
 	}
 	title += " | " + repoDisplayName
 
+	// Build fallback description matching title information
+	descFallback := "Browse "
+	if path != "." {
+		descFallback += path + " at "
+	}
+	if isCommitHash && len(ref) > 7 {
+		descFallback += ref[:7]
+	} else {
+		descFallback += ref
+	}
+	descFallback += " in " + repoDisplayName
+
+	description := getRepoDescriptionOrFallback(repo, descFallback)
+
 	data := TreeData{
 		RepoBaseData: RepoBaseData{
 			BaseData: BaseData{
-				ServerName: cfg.Name,
-				ActiveTab:  "tree",
-				Title:      title,
+				ServerName:  cfg.Name,
+				ActiveTab:   "tree",
+				Title:       title,
+				Description: description,
 			},
 			Repo:          repo,
 			DefaultBranch: defaultBranch,