Gitfed
bastien-mrq/gitfed/ Commits/ 31351eb

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>

bastien-mrq 2026-07-30 14:25 commit 31351ebef4765eac76edb0c40d5df102f159e84d parent 0d5628f589ded9eb20767eab44b570fd0877b326
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
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go index 3b7b077..989e993 100644 --- a/cmd/gitfed-web/handlers_repo.go +++ b/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
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go index e2ee5ed..5903f1e 100644 --- a/cmd/gitfed-web/render.go +++ b/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
diff --git a/internal/admin/admin.go b/internal/admin/admin.go index c8a53c1..d96580f 100644 --- a/internal/admin/admin.go +++ b/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
diff --git a/internal/admin/admin_test.go b/internal/admin/admin_test.go index e10e6e1..7b9a400 100644 --- a/internal/admin/admin_test.go +++ b/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
diff --git a/internal/adminrpc/client.go b/internal/adminrpc/client.go index d53a8ea..581a062 100644 --- a/internal/adminrpc/client.go +++ b/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
diff --git a/internal/adminrpc/protocol.go b/internal/adminrpc/protocol.go index d224c89..896104b 100644 --- a/internal/adminrpc/protocol.go +++ b/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
diff --git a/internal/adminrpc/server.go b/internal/adminrpc/server.go index 0af8fe1..a9badbe 100644 --- a/internal/adminrpc/server.go +++ b/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 {