diff --git a/pkg/web/webui_about.go b/pkg/web/webui_about.go index b3f0c75cab369559142a772921ff643ccb8f5961..b0165eb1d17817fac5e05e3debd7e6cef2150bea 100644 --- a/pkg/web/webui_about.go +++ b/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, } diff --git a/pkg/web/webui_blob.go b/pkg/web/webui_blob.go index 843eff380c88c8c023d7c0a173ad0e38ad3a6317..878156497e637ab1f047d9c593d72f8db044632b 100644 --- a/pkg/web/webui_blob.go +++ b/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, diff --git a/pkg/web/webui_branches.go b/pkg/web/webui_branches.go index 1d632443fbc7275395ce4708be4822990c86b247..26bf33a6a98fbdf018cab838b3e5be3c82489c9f 100644 --- a/pkg/web/webui_branches.go +++ b/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, diff --git a/pkg/web/webui_commits.go b/pkg/web/webui_commits.go index 46cf892fd07965a933981852e239e6f3caabffe9..b339afa8688616da14495f801745da69d44eeb00 100644 --- a/pkg/web/webui_commits.go +++ b/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, diff --git a/pkg/web/webui_helpers.go b/pkg/web/webui_helpers.go index d3d55fba12d4ae97496edfb1a488b6ee2fd9746c..7beae31a84e4e9368d968463ddfb016935ea4b38 100644 --- a/pkg/web/webui_helpers.go +++ b/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) +} diff --git a/pkg/web/webui_home.go b/pkg/web/webui_home.go index e54fbaccd4f86a219746fdc5948897b723d7ddc8..e7846abd3a512b827e9b3c3d4522a9a1ffa1347a 100644 --- a/pkg/web/webui_home.go +++ b/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, diff --git a/pkg/web/webui_tags.go b/pkg/web/webui_tags.go index 032faa2929772517b15260762853c2c2808ea128..416b9534bb76c25274853bd535f1a2d0ada06dc9 100644 --- a/pkg/web/webui_tags.go +++ b/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, diff --git a/pkg/web/webui_tree.go b/pkg/web/webui_tree.go index 93297a20e3d6ea990b71851f5891a79a17c53e56..70d18bcd09e5362c5e37444517f18a86bd0eb08b 100644 --- a/pkg/web/webui_tree.go +++ b/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,