README preview: language switcher for README.<lang>.md siblings
When a repo carries README.fr.md, README.es.md etc. next to its main README.md, the repo page now shows small pills above the preview to switch between them (?readme_lang=). Unrecognized language codes 404 rather than falling back silently, same treatment already used for ?branch= and ?ref=. New ListRepoReadmeLanguages/GetRepoReadmeLang on admin.Ops, threaded through adminrpc. Verified live: default view shows "orig"/"fr" pills, ?readme_lang=fr renders the French file, ?readme_lang=de 404s. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
7 files changed
+229 −4
M
cmd/gitfed-web/handlers_repo.go
+71 −4
M
cmd/gitfed-web/render.go
+1 −0
M
internal/admin/admin.go
+43 −0
M
internal/admin/admin_test.go
+75 −0
M
internal/adminrpc/client.go
+12 −0
M
internal/adminrpc/protocol.go
+11 −0
M
internal/adminrpc/server.go
+16 −0
cmd/gitfed-web/handlers_repo.go
@@ -107,6 +107,39 @@ func branchOptions(name string, branches []string, path, active string) []branch
return views
}
+type readmeLangOptionView struct {
+ Label string
+ URL string
+ Active bool
+}
+
+// readmeLangOptions builds the repo page's README language switcher — one
+// entry for the main README plus one per README.<lang>.md sibling found at
+// HEAD. All link back to the repo root, since these previews are only ever
+// shown there (see handleRepoView's onDefaultBranch/path=="" gate).
+func readmeLangOptions(name string, langs []string, active string) []readmeLangOptionView {
+ if len(langs) == 0 {
+ return nil
+ }
+ base := "/r/" + url.PathEscape(name)
+ views := make([]readmeLangOptionView, 0, len(langs)+1)
+ views = append(views, readmeLangOptionView{Label: strings.ToUpper(defaultReadmeLangLabel), URL: base, Active: active == ""})
+ for _, l := range langs {
+ views = append(views, readmeLangOptionView{
+ Label: strings.ToUpper(l),
+ URL: base + "?readme_lang=" + url.QueryEscape(l),
+ Active: active == l,
+ })
+ }
+ return views
+}
+
+// defaultReadmeLangLabel labels the main README (README.md, not a
+// README.<lang>.md sibling) in the language switcher — there's no reliable
+// way to detect its actual language, so it's labeled generically rather
+// than guessed.
+const defaultReadmeLangLabel = "orig"
+
// collaboratorView adds display-only context to a store.Collaborator: only
// the settings page needs to distinguish local from federated principals,
// so it's computed there rather than carried on the ACL model itself.
@@ -218,7 +251,14 @@ var repoTpl = newTpl("repo", `
{{if .ReadmeHTML}}
<div class="gf-card">
- <div class="gf-readme-head">{{icon "file"}} README</div>
+ <div class="gf-readme-head">
+ {{icon "file"}} README
+ {{if .ReadmeLangs}}
+ <span class="gf-readme-langs">
+ {{range .ReadmeLangs}}{{if .Active}}<span class="badge trusted">{{.Label}}</span>{{else}}<a href="{{.URL}}" class="badge plain">{{.Label}}</a>{{end}} {{end}}
+ </span>
+ {{end}}
+ </div>
<div class="gf-readme-body markdown-body">{{.ReadmeHTML}}</div>
</div>
{{end}}
@@ -304,6 +344,7 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
commitCount, _, _ := s.ops.CountCommits(name)
var readmeHTML template.HTML
+ var readmeLangViews []readmeLangOptionView
var licenseFile, licenseType string
var tags []string
var contributorCount int
@@ -314,13 +355,38 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
// ref-aware variant of these two, and showing (say) main's README
// while the file list below is some other branch's would be more
// confusing than just not showing a README at all here.
- readmeContent, readmeFound, err := s.ops.GetRepoReadme(name)
+ readmeLangs, err := s.ops.ListRepoReadmeLanguages(name)
+ if err != nil {
+ s.serverError(w, r, err)
+ return
+ }
+
+ // A ?readme_lang= naming a real README.<lang>.md sibling switches
+ // the preview to it; anything else is a 404, same "unknown value
+ // 404s" treatment used for ?branch= above.
+ readmeLang := r.URL.Query().Get("readme_lang")
+ if readmeLang != "" && !containsBranch(readmeLangs, readmeLang) {
+ http.NotFound(w, r)
+ return
+ }
+ readmeLangViews = readmeLangOptions(name, readmeLangs, readmeLang)
+
+ var readmeContent string
+ var readmeFound bool
+ var readmeFile string
+ if readmeLang != "" {
+ readmeContent, readmeFound, err = s.ops.GetRepoReadmeLang(name, readmeLang)
+ readmeFile = "README." + readmeLang + ".md"
+ } else {
+ readmeContent, readmeFound, err = s.ops.GetRepoReadme(name)
+ readmeFile = "README.md"
+ }
if err != nil {
s.serverError(w, r, err)
return
}
if readmeFound {
- readmeHTML, err = renderFileContent(name, "", "README.md", readmeContent)
+ readmeHTML, err = renderFileContent(name, "", readmeFile, readmeContent)
if err != nil {
s.serverError(w, r, err)
return
@@ -366,6 +432,7 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
Empty bool
Tags []string
ReadmeHTML template.HTML
+ ReadmeLangs []readmeLangOptionView
LicenseFile string
LicenseType string
ContributorCount int
@@ -376,7 +443,7 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
repo, s.domain, "ssh://git@" + s.domain + ":2222/" + name + ".git", "https://" + s.domain + "/" + name + ".git", branch,
branchOptions(name, branches, path, branch), commitCount, string(lang),
breadcrumbs(path), views, path != "", parentPath(path), path == "" && !found,
- tags, readmeHTML, licenseFile, licenseType, contributorCount, dominantLanguage, canAdminister, flash(r),
+ tags, readmeHTML, readmeLangViews, licenseFile, licenseType, contributorCount, dominantLanguage, canAdminister, flash(r),
})
title := name
cmd/gitfed-web/render.go
@@ -467,6 +467,7 @@ const shellHeadSrc = `<!doctype html>
.gf-file-table td.name a:hover { color: var(--accent); text-decoration: underline; }
.gf-file-table td.meta { color: var(--text-faint); font-size: 0.78rem; text-align: right; white-space: nowrap; }
.gf-readme-head { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1.1rem; border-bottom: 1px solid var(--border); font-size: 0.86rem; color: var(--text-dim); font-family: var(--mono); }
+ .gf-readme-langs { margin-left: auto; display: flex; gap: 0.35rem; }
.gf-readme-body { padding: 1.3rem; }
.gf-license-card { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.85rem 1.1rem; font-size: 0.86rem; color: var(--text-dim); font-family: var(--mono); }
internal/admin/admin.go
@@ -9,6 +9,8 @@ import (
"log"
"net/url"
"os"
+ "regexp"
+ "sort"
"strings"
"time"
@@ -54,6 +56,8 @@ type Ops interface {
SetRepoTopics(name string, topics []string) error
SetRepoDescription(name, description string) error
GetRepoReadme(name string) (content string, found bool, err error)
+ ListRepoReadmeLanguages(name string) (langs []string, err error)
+ GetRepoReadmeLang(name, lang string) (content string, found bool, err error)
GetRepoLicense(name string) (content, filename string, found bool, err error)
ListRepoTags(name string) ([]string, error)
GetRepoArchive(name, ref, format string) (data []byte, err error)
@@ -393,6 +397,45 @@ func (a *Admin) GetRepoReadme(name string) (string, bool, error) {
return "", false, nil
}
+// readmeLangPattern matches README.<lang>.md sibling files at the repo root
+// (e.g. README.fr.md), for the repo page's language switcher.
+var readmeLangPattern = regexp.MustCompile(`(?i)^readme\.([a-z]{2})\.md$`)
+
+// ListRepoReadmeLanguages returns the language codes of README.<lang>.md
+// sibling files found next to the repo's main README at HEAD, sorted.
+func (a *Admin) ListRepoReadmeLanguages(name string) ([]string, error) {
+ repo, err := a.Store.GetRepo(name)
+ if err != nil {
+ return nil, err
+ }
+ entries, found, err := gitexec.ListTree(repo.Path, "")
+ if err != nil || !found {
+ return nil, err
+ }
+ var langs []string
+ for _, e := range entries {
+ if e.Type != "blob" {
+ continue
+ }
+ if m := readmeLangPattern.FindStringSubmatch(e.Name); m != nil {
+ langs = append(langs, strings.ToLower(m[1]))
+ }
+ }
+ sort.Strings(langs)
+ return langs, nil
+}
+
+// GetRepoReadmeLang returns README.<lang>.md's content at HEAD. The caller
+// must have already validated lang against ListRepoReadmeLanguages — same
+// contract as ListRepoTreeAtRef.
+func (a *Admin) GetRepoReadmeLang(name, lang string) (string, bool, error) {
+ repo, err := a.Store.GetRepo(name)
+ if err != nil {
+ return "", false, err
+ }
+ return gitexec.ReadFileAtHEAD(repo.Path, "README."+lang+".md")
+}
+
func (a *Admin) GetRepoLicense(name string) (string, string, bool, error) {
repo, err := a.Store.GetRepo(name)
if err != nil {
internal/admin/admin_test.go
@@ -3,7 +3,10 @@ package admin
import (
"crypto/ed25519"
"crypto/rand"
+ "os"
+ "os/exec"
"path/filepath"
+ "reflect"
"strings"
"testing"
@@ -219,6 +222,78 @@ func TestCloseMergeRequestDoesNotOverwriteMerged(t *testing.T) {
}
}
+// runGit is a minimal test helper to populate a real bare repo via a work
+// checkout — same pattern as internal/gitexec's test suite.
+func runGit(t *testing.T, dir string, args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = dir
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@local.test",
+ "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@local.test")
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+}
+
+func TestReadmeLanguageVariants(t *testing.T) {
+ s, err := store.Open(filepath.Join(t.TempDir(), "gitfed.db"))
+ if err != nil {
+ t.Fatalf("open store: %v", err)
+ }
+ defer s.Close()
+ a := New(s, nil, "local.test", t.TempDir(), testCA(t))
+
+ if err := a.CreateRepo("alice/repo", "alice"); err != nil {
+ t.Fatalf("create repo: %v", err)
+ }
+ repo, err := s.GetRepo("alice/repo")
+ if err != nil {
+ t.Fatalf("get repo: %v", err)
+ }
+
+ work := filepath.Join(t.TempDir(), "work")
+ if err := os.Mkdir(work, 0755); err != nil {
+ t.Fatal(err)
+ }
+ runGit(t, work, "init", "-q", "-b", "main")
+ for _, f := range []string{"README.md", "README.fr.md", "README.es.md", "CONTRIBUTING.md"} {
+ if err := os.WriteFile(filepath.Join(work, f), []byte("content of "+f), 0644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ runGit(t, work, "add", ".")
+ runGit(t, work, "commit", "-q", "-m", "seed")
+ runGit(t, work, "remote", "add", "origin", repo.Path)
+ runGit(t, work, "push", "-q", "origin", "main")
+
+ langs, err := a.ListRepoReadmeLanguages("alice/repo")
+ if err != nil {
+ t.Fatalf("ListRepoReadmeLanguages: %v", err)
+ }
+ // Sorted, and CONTRIBUTING.md must not be mistaken for a variant.
+ if want := []string{"es", "fr"}; !reflect.DeepEqual(langs, want) {
+ t.Fatalf("langs = %v, want %v", langs, want)
+ }
+
+ content, found, err := a.GetRepoReadmeLang("alice/repo", "fr")
+ if err != nil {
+ t.Fatalf("GetRepoReadmeLang: %v", err)
+ }
+ if !found || content != "content of README.fr.md" {
+ t.Fatalf("GetRepoReadmeLang(fr) = %q, found=%v, want %q, true", content, found, "content of README.fr.md")
+ }
+
+ // A language with no sibling file just isn't found — not an error.
+ _, found, err = a.GetRepoReadmeLang("alice/repo", "de")
+ if err != nil {
+ t.Fatalf("GetRepoReadmeLang(de): %v", err)
+ }
+ if found {
+ t.Fatal("GetRepoReadmeLang(de) found=true, want false — no README.de.md was pushed")
+ }
+}
+
func validTestKey(t *testing.T) string {
t.Helper()
pub, _, err := ed25519.GenerateKey(rand.Reader)
internal/adminrpc/client.go
@@ -199,6 +199,18 @@ func (c *Client) GetRepoReadme(name string) (string, bool, error) {
return out.Content, out.Found, err
}
+func (c *Client) ListRepoReadmeLanguages(name string) ([]string, error) {
+ var out readmeLangsResult
+ err := c.call(methodListReadmeLangs, nameArgs{Name: name}, &out)
+ return out.Langs, err
+}
+
+func (c *Client) GetRepoReadmeLang(name, lang string) (string, bool, error) {
+ var out readmeResult
+ err := c.call(methodGetRepoReadmeLang, nameLangArgs{Name: name, Lang: lang}, &out)
+ return out.Content, out.Found, err
+}
+
func (c *Client) GetRepoLicense(name string) (string, string, bool, error) {
var out licenseResult
err := c.call(methodGetRepoLicense, nameArgs{Name: name}, &out)
internal/adminrpc/protocol.go
@@ -35,6 +35,8 @@ const (
methodSetRepoTopics = "SetRepoTopics"
methodSetRepoDescription = "SetRepoDescription"
methodGetRepoReadme = "GetRepoReadme"
+ methodListReadmeLangs = "ListRepoReadmeLanguages"
+ methodGetRepoReadmeLang = "GetRepoReadmeLang"
methodGetRepoLicense = "GetRepoLicense"
methodListRepoTags = "ListRepoTags"
methodGetRepoArchive = "GetRepoArchive"
@@ -177,6 +179,15 @@ type readmeResult struct {
Found bool `json:"found"`
}
+type nameLangArgs struct {
+ Name string `json:"name"`
+ Lang string `json:"lang"`
+}
+
+type readmeLangsResult struct {
+ Langs []string `json:"langs"`
+}
+
type licenseResult struct {
Content string `json:"content"`
Filename string `json:"filename"`
internal/adminrpc/server.go
@@ -203,6 +203,22 @@ func (s *Server) dispatch(req wireRequest) (any, error) {
content, found, err := s.ops.GetRepoReadme(a.Name)
return readmeResult{Content: content, Found: found}, err
+ case methodListReadmeLangs:
+ var a nameArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ langs, err := s.ops.ListRepoReadmeLanguages(a.Name)
+ return readmeLangsResult{Langs: langs}, err
+
+ case methodGetRepoReadmeLang:
+ var a nameLangArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ content, found, err := s.ops.GetRepoReadmeLang(a.Name, a.Lang)
+ return readmeResult{Content: content, Found: found}, err
+
case methodGetRepoLicense:
var a nameArgs
if err := json.Unmarshal(req.Args, &a); err != nil {