Commit 84c6f81258

84c6f812580593d9a2a4109016cc011267a9cadc

parent: 5c310a0e31

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-28 03:56 UTC

wiki: pages in subfolders

Pages under .gitbay/wiki/ may sit in subfolders and are named by their
path (Guide/Install). wiki list and hasWiki read the tree recursively,
the page route takes the rest of the path, and the sidebar groups
pages by folder. A relative link or image on a nested page resolves
from its folder first, then the wiki root; top-level pages resolve as
before. The wiki raw route serves images with their content type, so
SVGs render.

Closes #272
.gitbay/wiki/Users.org +7
@@ -344,6 +344,13 @@ mkdir -p .gitbay/wiki
344344git add .gitbay/wiki && git commit -m "wiki" && git push
345345#+end_src
346346
347Pages may sit in subfolders. A nested page is named by its path —
348=.gitbay/wiki/Guide/Install.md= is =/you/project/wiki/Guide/Install= and
349=wiki show you/project Guide/Install= — and the sidebar groups it under
350its folder. A relative link or image on a nested page resolves from the
351page's own folder first, then from the top of the wiki, so a link to
352=Home= still reaches the top-level page. SVG images render.
353
347354Releases anchor notes and binary assets to a pushed tag (write access;
348355assets stream over SSH, capped by the instance's =max_asset_bytes=).
349356While a release exists its tag can be neither deleted nor moved, whether
e2e/wiki_test.go +44
@@ -1,6 +1,7 @@
11package e2e
22
33import (
4 "net/http"
45 "os"
56 "path/filepath"
67 "strings"
@@ -90,6 +91,46 @@ func TestWikis(t *testing.T) {
9091 t.Fatalf("wiki raw escaped .gitbay/wiki: %d\n%s", status, body)
9192 }
9293
94 // A page in a subfolder is named by its path. Its links resolve from
95 // its own folder first and then the wiki root, the sidebar groups it
96 // under the folder, and an SVG next to it is served as an image.
97 guide := filepath.Join(dir, ".gitbay", "wiki", "Guide")
98 os.MkdirAll(guide, 0o755)
99 os.WriteFile(filepath.Join(guide, "Install.md"), []byte(
100 "# install steps\n\n[Next](Next.md), [Setup](Setup.org)\n\n![flow](flow.svg)\n"), 0o644)
101 os.WriteFile(filepath.Join(guide, "Next.md"), []byte("# next step\n"), 0o644)
102 os.WriteFile(filepath.Join(guide, "flow.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`), 0o644)
103 mustGit(t, dir, env, "add", ".")
104 mustGit(t, dir, env, "commit", "-q", "-m", "wiki subfolder")
105 mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/app"), "main")
106 status, body = inst.get(t, "/alice/app/wiki/Guide/Install")
107 if status != 200 || !strings.Contains(body, "install steps") {
108 t.Fatalf("subfolder page: %d\n%s", status, body)
109 }
110 for _, want := range []string{
111 `href="/alice/app/wiki/Guide/Next"`,
112 `href="/alice/app/wiki/Setup"`,
113 `src="/alice/app/wiki/_raw/Guide/flow.svg"`,
114 `<p class="meta wikidir">Guide</p>`,
115 `href="/alice/app/wiki/Guide/Install">Install</a>`,
116 } {
117 if !strings.Contains(body, want) {
118 t.Errorf("subfolder page lacks %s", want)
119 }
120 }
121 resp, err := http.Get(inst.base() + "/alice/app/wiki/_raw/Guide/flow.svg")
122 if err != nil {
123 t.Fatal(err)
124 }
125 resp.Body.Close()
126 if ct := resp.Header.Get("Content-Type"); resp.StatusCode != 200 || ct != "image/svg+xml" {
127 t.Errorf("wiki svg: %d %q", resp.StatusCode, ct)
128 }
129 out, _, code := inst.ssh(t, aliceKey, "", "wiki", "show", "alice/app", "Guide/Next", "--json")
130 if code != 0 || !strings.Contains(out, "next step") {
131 t.Errorf("wiki show Guide/Next: %s", out)
132 }
133
93134 // A wiki is readable from every surface, not just a browser: the
94135 // commands are what the web dispatches, and what the CLI and the
95136 // JSON API reach.
@@ -100,6 +141,9 @@ func TestWikis(t *testing.T) {
100141 if !strings.Contains(out, `"Home"`) || !strings.Contains(out, `"Setup"`) {
101142 t.Errorf("wiki list pages: %s", out)
102143 }
144 if !strings.Contains(out, `"Guide/Install"`) || !strings.Contains(out, `"Guide/Next"`) {
145 t.Errorf("wiki list lacks subfolder pages: %s", out)
146 }
103147 if !strings.Contains(out, `"home":"Home"`) {
104148 t.Errorf("wiki list did not name the landing page: %s", out)
105149 }
internal/control/wiki.go +2 −1
@@ -58,8 +58,9 @@ func wikiDir(c *Ctx, spec string) (repo store.Repo, dir, branch string, code int
5858}
5959
6060// wikiPages lists the page names under .gitbay/wiki, without extensions.
61// A page in a subfolder is named by its path: Architecture/Identity.
6162func wikiPages(dir, branch string) []string {
62 entries, err := gitutil.ListTree(dir, branch, wikiTreePath)
63 entries, err := gitutil.ListTreeRecursive(dir, branch, wikiTreePath)
6364 if err != nil {
6465 return nil // no .gitbay/wiki tree on this branch
6566 }
internal/gitutil/read.go +15 −1
@@ -24,11 +24,25 @@ type TreeEntry struct {
2424
2525// ListTree lists one level of the tree at ref:path.
2626func ListTree(dir, ref, path string) ([]TreeEntry, error) {
27 return lsTree(dir, ref, path, false)
28}
29
30// ListTreeRecursive lists every blob under ref:path, named by its path
31// relative to that tree.
32func ListTreeRecursive(dir, ref, path string) ([]TreeEntry, error) {
33 return lsTree(dir, ref, path, true)
34}
35
36func lsTree(dir, ref, path string, recursive bool) ([]TreeEntry, error) {
2737 spec := ref
2838 if path != "" {
2939 spec = ref + ":" + path
3040 }
31 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "ls-tree", "-l", "--end-of-options", spec)
41 args := []string{"-C", dir, "ls-tree", "-l"}
42 if recursive {
43 args = append(args, "-r")
44 }
45 cmd := exec.Command(toolpath.Look("git"), append(args, "--end-of-options", spec)...)
3246 out, err := cmd.Output()
3347 if err != nil {
3448 return nil, fmt.Errorf("ls-tree %s: %w", spec, err)
internal/httpd/routes.go +1 −1
@@ -69,7 +69,7 @@ func (s *Server) Routes() []Route {
6969 Route{Method: "GET", Pattern: "/{owner}/{repo}/labels", Handler: s.labels},
7070 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki", Handler: s.wiki},
7171 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/_raw/{path...}", Handler: s.wikiRaw},
72 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/{page}", Handler: s.wiki},
72 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/{page...}", Handler: s.wiki},
7373 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases", Handler: s.releases},
7474 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases.atom", Handler: s.releasesAtom},
7575 Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom", Handler: s.logAtom},
internal/httpd/wiki.go +101 −19
@@ -4,6 +4,7 @@ import (
44 "html/template"
55 "net/http"
66 "path"
7 "sort"
78 "strings"
89
910 "golang.org/x/net/html"
@@ -29,7 +30,7 @@ func (s *Server) wikiDir(repo store.Repo) (dir, branch string) {
2930// hasWiki reports whether repo's default branch holds a wiki page.
3031func (s *Server) hasWiki(repo store.Repo) bool {
3132 dir, branch := s.wikiDir(repo)
32 entries, err := gitutil.ListTree(dir, branch, wikiTreePath)
33 entries, err := gitutil.ListTreeRecursive(dir, branch, wikiTreePath)
3334 if err != nil {
3435 return false
3536 }
@@ -54,7 +55,8 @@ func wikiExtMatch(name string) bool {
5455
5556// wiki renders a page from the repo's .gitbay/wiki tree. The home page is
5657// Home.<ext> (or README.<ext>); /wiki/<name> resolves <name> with .md and
57// .org fallbacks. Rendering reuses the same sanitized pipeline as READMEs.
58// .org fallbacks, and a name may be a path into a subfolder. Rendering
59// reuses the same sanitized pipeline as READMEs.
5860func (s *Server) wiki(w http.ResponseWriter, r *http.Request) {
5961 p, ok := s.repoFor(w, r, "")
6062 if !ok {
@@ -75,7 +77,7 @@ func (s *Server) wiki(w http.ResponseWriter, r *http.Request) {
7577 repoPage
7678 Page string
7779 PageHTML template.HTML
78 Pages []string
80 Nav []wikiNavGroup
7981 Missing bool
8082 }{repoPage: p, Missing: true})
8183 return
@@ -97,15 +99,66 @@ func (s *Server) wiki(w http.ResponseWriter, r *http.Request) {
9799 s.notFound(w, r)
98100 return
99101 }
100 pageHTML = rewriteWikiLinks(renderReadme(shown.File, []byte(shown.Content)), p)
102 isPage := map[string]bool{}
103 for _, pg := range pages {
104 isPage[pg] = true
105 }
106 isFile := map[string]bool{}
107 dir, branch := s.wikiDir(p.Repo)
108 if entries, err := gitutil.ListTreeRecursive(dir, branch, wikiTreePath); err == nil {
109 for _, e := range entries {
110 isFile[e.Name] = e.Type == "blob"
111 }
112 }
113 pageHTML = rewriteWikiLinks(renderReadme(shown.File, []byte(shown.Content)), p, page,
114 func(t string) bool { return isPage[t] }, func(t string) bool { return isFile[t] })
101115 }
102116 s.render(w, "wiki.html", struct {
103117 repoPage
104118 Page string
105119 PageHTML template.HTML
106 Pages []string
120 Nav []wikiNavGroup
107121 Missing bool
108 }{p, page, pageHTML, pages, false})
122 }{p, page, pageHTML, wikiNav(pages), false})
123}
124
125// wikiNavGroup is one folder of the wiki sidebar; Dir is empty for the
126// top level, which comes first.
127type wikiNavGroup struct {
128 Dir string
129 Pages []wikiNavPage
130}
131
132type wikiNavPage struct {
133 Name string // the page's path, as routed
134 Label string // its last segment
135}
136
137// wikiNav groups page names by folder, keeping the listing's order within
138// each folder.
139func wikiNav(pages []string) []wikiNavGroup {
140 var groups []wikiNavGroup
141 at := map[string]int{}
142 for _, pg := range pages {
143 d := path.Dir(pg)
144 if d == "." {
145 d = ""
146 }
147 i, ok := at[d]
148 if !ok {
149 i = len(groups)
150 at[d] = i
151 groups = append(groups, wikiNavGroup{Dir: d})
152 }
153 groups[i].Pages = append(groups[i].Pages, wikiNavPage{Name: pg, Label: path.Base(pg)})
154 }
155 sort.SliceStable(groups, func(a, b int) bool {
156 if (groups[a].Dir == "") != (groups[b].Dir == "") {
157 return groups[a].Dir == ""
158 }
159 return groups[a].Dir < groups[b].Dir
160 })
161 return groups
109162}
110163
111164// wikiRaw serves non-page files from the wiki (images referenced by pages).
@@ -125,15 +178,49 @@ func (s *Server) wikiRaw(w http.ResponseWriter, r *http.Request) {
125178 s.notFound(w, r)
126179 return
127180 }
128 w.Header().Set("Content-Type", "application/octet-stream")
181 ct := "application/octet-stream"
182 if t, ok := imageTypes[strings.ToLower(path.Ext(rel))]; ok {
183 ct = t
184 }
185 w.Header().Set("Content-Type", ct)
129186 w.Header().Set("X-Content-Type-Options", "nosniff")
130187 w.Write(data)
131188}
132189
190// wikiResolve maps a relative link on page to a path inside the wiki. A
191// page in a subfolder looks in its own folder first and then at the wiki
192// root, so [[Admin]] still reaches a top-level page; when neither exists
193// the folder-relative path is kept, so a broken link points where its
194// author wrote it. A top-level page resolves exactly as before.
195func wikiResolve(page, v string, exists func(string) bool) (string, bool) {
196 dir := path.Dir(page)
197 local := path.Clean(path.Join(dir, v))
198 if local == ".." || strings.HasPrefix(local, "../") {
199 return "", false
200 }
201 if dir == "." || exists(local) {
202 return local, true
203 }
204 if root := path.Clean(v); root != ".." && !strings.HasPrefix(root, "../") && exists(root) {
205 return root, true
206 }
207 return local, true
208}
209
210// trimPageExt drops a page extension from a link target.
211func trimPageExt(target string) string {
212 switch strings.ToLower(path.Ext(target)) {
213 case ".md", ".org", ".markdown", ".html":
214 return strings.TrimSuffix(target, path.Ext(target))
215 }
216 return target
217}
218
133219// rewriteWikiLinks makes relative links resolve inside the wiki: page
134220// links (with or without .md/.org/.html extensions) go to /wiki/<page>,
135// other relative targets (images) to the wiki raw route.
136func rewriteWikiLinks(rendered template.HTML, p repoPage) template.HTML {
221// other relative targets (images) to the wiki raw route. Targets resolve
222// from the current page's folder, as wikiResolve describes.
223func rewriteWikiLinks(rendered template.HTML, p repoPage, page string, isPage, isFile func(string) bool) template.HTML {
137224 ctx := &html.Node{Type: html.ElementNode, Data: "div", DataAtom: atom.Div}
138225 nodes, err := html.ParseFragment(strings.NewReader(string(rendered)), ctx)
139226 if err != nil {
@@ -155,20 +242,15 @@ func rewriteWikiLinks(rendered template.HTML, p repoPage) template.HTML {
155242 strings.HasPrefix(v, "data:") {
156243 continue
157244 }
158 target := path.Clean(v)
159 if strings.HasPrefix(target, "..") {
160 continue
161 }
162245 if isSrc {
163 n.Attr[i].Val = base + "/_raw/" + target
246 if target, ok := wikiResolve(page, v, isFile); ok {
247 n.Attr[i].Val = base + "/_raw/" + target
248 }
164249 continue
165250 }
166 ext := strings.ToLower(path.Ext(target))
167 switch ext {
168 case ".md", ".org", ".markdown", ".html":
169 target = strings.TrimSuffix(target, path.Ext(target))
251 if target, ok := wikiResolve(page, trimPageExt(v), isPage); ok {
252 n.Attr[i].Val = base + "/" + target
170253 }
171 n.Attr[i].Val = base + "/" + target
172254 }
173255 }
174256 for c := n.FirstChild; c != nil; c = c.NextSibling {
internal/httpd/wiki_test.go added +70
@@ -0,0 +1,70 @@
1package httpd
2
3import (
4 "html/template"
5 "reflect"
6 "strings"
7 "testing"
8
9 "gitbay.org/gitbay/internal/store"
10)
11
12func TestWikiResolve(t *testing.T) {
13 have := map[string]bool{"Admin": true, "Architecture/Identity": true, "Architecture/Deep/Page": true}
14 exists := func(s string) bool { return have[s] }
15 cases := []struct {
16 page, link, want string
17 ok bool
18 }{
19 {"Home", "Admin", "Admin", true},
20 {"Home", "Missing", "Missing", true},
21 {"Home", "../etc/passwd", "", false},
22 {"Architecture/Trust", "Identity", "Architecture/Identity", true},
23 {"Architecture/Trust", "Admin", "Admin", true},
24 {"Architecture/Trust", "Nowhere", "Architecture/Nowhere", true},
25 {"Architecture/Trust", "../Admin", "Admin", true},
26 {"Architecture/Trust", "Deep/Page", "Architecture/Deep/Page", true},
27 {"Architecture/Deep/Page", "../Identity", "Architecture/Identity", true},
28 {"Architecture/Trust", "../../x", "", false},
29 {"", "Admin", "Admin", true},
30 }
31 for _, c := range cases {
32 got, ok := wikiResolve(c.page, c.link, exists)
33 if got != c.want || ok != c.ok {
34 t.Errorf("wikiResolve(%q, %q) = %q, %v; want %q, %v", c.page, c.link, got, ok, c.want, c.ok)
35 }
36 }
37}
38
39func TestRewriteWikiLinksInSubfolder(t *testing.T) {
40 p := repoPage{Repo: store.Repo{OwnerName: "krz", Name: "gitbay"}}
41 pages := map[string]bool{"Admin": true, "Architecture/Identity": true}
42 files := map[string]bool{"diagrams/a.svg": true, "Architecture/b.svg": true}
43 in := template.HTML(`<a href="Identity.org">i</a><a href="Admin.org">a</a>` +
44 `<img src="b.svg"><img src="diagrams/a.svg"><a href="https://x.test/">x</a>`)
45 out := string(rewriteWikiLinks(in, p, "Architecture/Trust",
46 func(s string) bool { return pages[s] }, func(s string) bool { return files[s] }))
47 for _, want := range []string{
48 `href="/krz/gitbay/wiki/Architecture/Identity"`,
49 `href="/krz/gitbay/wiki/Admin"`,
50 `src="/krz/gitbay/wiki/_raw/Architecture/b.svg"`,
51 `src="/krz/gitbay/wiki/_raw/diagrams/a.svg"`,
52 `href="https://x.test/"`,
53 } {
54 if !strings.Contains(out, want) {
55 t.Errorf("missing %s in\n%s", want, out)
56 }
57 }
58}
59
60func TestWikiNav(t *testing.T) {
61 got := wikiNav([]string{"API", "Architecture/Identity", "Architecture/Trust", "Home", "Ops/Backups"})
62 want := []wikiNavGroup{
63 {Dir: "", Pages: []wikiNavPage{{"API", "API"}, {"Home", "Home"}}},
64 {Dir: "Architecture", Pages: []wikiNavPage{{"Architecture/Identity", "Identity"}, {"Architecture/Trust", "Trust"}}},
65 {Dir: "Ops", Pages: []wikiNavPage{{"Ops/Backups", "Backups"}}},
66 }
67 if !reflect.DeepEqual(got, want) {
68 t.Errorf("wikiNav = %+v\nwant %+v", got, want)
69 }
70}
internal/web/static/style.css +1
@@ -1748,6 +1748,7 @@ h2 .count { background: none; color: var(--muted); font-weight: 400; font-size:
17481748.wikipage { flex: 1; min-width: 0; }
17491749.wikinav { flex: none; width: 15rem; }
17501750.wikinav ul { list-style: none; margin: var(--sp-1) 0 var(--sp-4); padding: 0; }
1751.wikinav .wikidir { margin-bottom: 0; }
17511752.wikinav li a {
17521753 display: block;
17531754 padding: 5px var(--sp-2);
internal/web/templates/wiki.html +2 −1
@@ -13,7 +13,8 @@ push <code>.gitbay/wiki/Home.md</code> (or .org).</p>
1313</div>
1414<nav class="wikinav">
1515<p class="meta">pages</p>
16<ul>{{range .Pages}}<li><a {{if eq . $.Page}}class="active" {{end}}href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/wiki/{{.}}">{{.}}</a></li>{{end}}</ul>
16{{range .Nav}}{{if .Dir}}<p class="meta wikidir">{{.Dir}}</p>{{end}}
17<ul>{{range .Pages}}<li><a {{if eq .Name $.Page}}class="active" {{end}}href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/wiki/{{.Name}}">{{.Label}}</a></li>{{end}}</ul>{{end}}
1718<p class="meta">edit under <code>.gitbay/wiki/</code> by push.</p>
1819</nav>
1920</div>