wiki: pages in subfolders !475
9 files changed, +243 −23
Layout: unified · split
.gitbay/wiki/Users.org +7
| @@ -344,6 +344,13 @@ mkdir -p .gitbay/wiki | |||
| 344 | git add .gitbay/wiki && git commit -m "wiki" && git push | 344 | git add .gitbay/wiki && git commit -m "wiki" && git push |
| 345 | #+end_src | 345 | #+end_src |
| 346 | 346 | ||
| 347 | Pages 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 | ||
| 350 | its folder. A relative link or image on a nested page resolves from the | ||
| 351 | page'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 | |||
| 347 | Releases anchor notes and binary assets to a pushed tag (write access; | 354 | Releases anchor notes and binary assets to a pushed tag (write access; |
| 348 | assets stream over SSH, capped by the instance's =max_asset_bytes=). | 355 | assets stream over SSH, capped by the instance's =max_asset_bytes=). |
| 349 | While a release exists its tag can be neither deleted nor moved, whether | 356 | While a release exists its tag can be neither deleted nor moved, whether |
e2e/wiki_test.go +44
| @@ -1,6 +1,7 @@ | |||
| 1 | package e2e | 1 | package e2e |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "net/http" | ||
| 4 | "os" | 5 | "os" |
| 5 | "path/filepath" | 6 | "path/filepath" |
| 6 | "strings" | 7 | "strings" |
| @@ -90,6 +91,46 @@ func TestWikis(t *testing.T) { | |||
| 90 | t.Fatalf("wiki raw escaped .gitbay/wiki: %d\n%s", status, body) | 91 | t.Fatalf("wiki raw escaped .gitbay/wiki: %d\n%s", status, body) |
| 91 | } | 92 | } |
| 92 | 93 | ||
| 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\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 | |||
| 93 | // A wiki is readable from every surface, not just a browser: the | 134 | // A wiki is readable from every surface, not just a browser: the |
| 94 | // commands are what the web dispatches, and what the CLI and the | 135 | // commands are what the web dispatches, and what the CLI and the |
| 95 | // JSON API reach. | 136 | // JSON API reach. |
| @@ -100,6 +141,9 @@ func TestWikis(t *testing.T) { | |||
| 100 | if !strings.Contains(out, `"Home"`) || !strings.Contains(out, `"Setup"`) { | 141 | if !strings.Contains(out, `"Home"`) || !strings.Contains(out, `"Setup"`) { |
| 101 | t.Errorf("wiki list pages: %s", out) | 142 | t.Errorf("wiki list pages: %s", out) |
| 102 | } | 143 | } |
| 144 | if !strings.Contains(out, `"Guide/Install"`) || !strings.Contains(out, `"Guide/Next"`) { | ||
| 145 | t.Errorf("wiki list lacks subfolder pages: %s", out) | ||
| 146 | } | ||
| 103 | if !strings.Contains(out, `"home":"Home"`) { | 147 | if !strings.Contains(out, `"home":"Home"`) { |
| 104 | t.Errorf("wiki list did not name the landing page: %s", out) | 148 | t.Errorf("wiki list did not name the landing page: %s", out) |
| 105 | } | 149 | } |
internal/control/wiki.go +2 −1
| @@ -58,8 +58,9 @@ func wikiDir(c *Ctx, spec string) (repo store.Repo, dir, branch string, code int | |||
| 58 | } | 58 | } |
| 59 | 59 | ||
| 60 | // wikiPages lists the page names under .gitbay/wiki, without extensions. | 60 | // wikiPages lists the page names under .gitbay/wiki, without extensions. |
| 61 | // A page in a subfolder is named by its path: Architecture/Identity. | ||
| 61 | func wikiPages(dir, branch string) []string { | 62 | func wikiPages(dir, branch string) []string { |
| 62 | entries, err := gitutil.ListTree(dir, branch, wikiTreePath) | 63 | entries, err := gitutil.ListTreeRecursive(dir, branch, wikiTreePath) |
| 63 | if err != nil { | 64 | if err != nil { |
| 64 | return nil // no .gitbay/wiki tree on this branch | 65 | return nil // no .gitbay/wiki tree on this branch |
| 65 | } | 66 | } |
internal/gitutil/read.go +15 −1
| @@ -24,11 +24,25 @@ type TreeEntry struct { | |||
| 24 | 24 | ||
| 25 | // ListTree lists one level of the tree at ref:path. | 25 | // ListTree lists one level of the tree at ref:path. |
| 26 | func ListTree(dir, ref, path string) ([]TreeEntry, error) { | 26 | func 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. | ||
| 32 | func ListTreeRecursive(dir, ref, path string) ([]TreeEntry, error) { | ||
| 33 | return lsTree(dir, ref, path, true) | ||
| 34 | } | ||
| 35 | |||
| 36 | func lsTree(dir, ref, path string, recursive bool) ([]TreeEntry, error) { | ||
| 27 | spec := ref | 37 | spec := ref |
| 28 | if path != "" { | 38 | if path != "" { |
| 29 | spec = ref + ":" + path | 39 | spec = ref + ":" + path |
| 30 | } | 40 | } |
| 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)...) | ||
| 32 | out, err := cmd.Output() | 46 | out, err := cmd.Output() |
| 33 | if err != nil { | 47 | if err != nil { |
| 34 | return nil, fmt.Errorf("ls-tree %s: %w", spec, err) | 48 | 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 { | |||
| 69 | Route{Method: "GET", Pattern: "/{owner}/{repo}/labels", Handler: s.labels}, | 69 | Route{Method: "GET", Pattern: "/{owner}/{repo}/labels", Handler: s.labels}, |
| 70 | Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki", Handler: s.wiki}, | 70 | Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki", Handler: s.wiki}, |
| 71 | Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/_raw/{path...}", Handler: s.wikiRaw}, | 71 | 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}, |
| 73 | Route{Method: "GET", Pattern: "/{owner}/{repo}/releases", Handler: s.releases}, | 73 | Route{Method: "GET", Pattern: "/{owner}/{repo}/releases", Handler: s.releases}, |
| 74 | Route{Method: "GET", Pattern: "/{owner}/{repo}/releases.atom", Handler: s.releasesAtom}, | 74 | Route{Method: "GET", Pattern: "/{owner}/{repo}/releases.atom", Handler: s.releasesAtom}, |
| 75 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom", Handler: s.logAtom}, | 75 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom", Handler: s.logAtom}, |
internal/httpd/wiki.go +101 −19
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | "html/template" | 4 | "html/template" |
| 5 | "net/http" | 5 | "net/http" |
| 6 | "path" | 6 | "path" |
| 7 | "sort" | ||
| 7 | "strings" | 8 | "strings" |
| 8 | 9 | ||
| 9 | "golang.org/x/net/html" | 10 | "golang.org/x/net/html" |
| @@ -29,7 +30,7 @@ func (s *Server) wikiDir(repo store.Repo) (dir, branch string) { | |||
| 29 | // hasWiki reports whether repo's default branch holds a wiki page. | 30 | // hasWiki reports whether repo's default branch holds a wiki page. |
| 30 | func (s *Server) hasWiki(repo store.Repo) bool { | 31 | func (s *Server) hasWiki(repo store.Repo) bool { |
| 31 | dir, branch := s.wikiDir(repo) | 32 | dir, branch := s.wikiDir(repo) |
| 32 | entries, err := gitutil.ListTree(dir, branch, wikiTreePath) | 33 | entries, err := gitutil.ListTreeRecursive(dir, branch, wikiTreePath) |
| 33 | if err != nil { | 34 | if err != nil { |
| 34 | return false | 35 | return false |
| 35 | } | 36 | } |
| @@ -54,7 +55,8 @@ func wikiExtMatch(name string) bool { | |||
| 54 | 55 | ||
| 55 | // wiki renders a page from the repo's .gitbay/wiki tree. The home page is | 56 | // wiki renders a page from the repo's .gitbay/wiki tree. The home page is |
| 56 | // Home.<ext> (or README.<ext>); /wiki/<name> resolves <name> with .md and | 57 | // 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. | ||
| 58 | func (s *Server) wiki(w http.ResponseWriter, r *http.Request) { | 60 | func (s *Server) wiki(w http.ResponseWriter, r *http.Request) { |
| 59 | p, ok := s.repoFor(w, r, "") | 61 | p, ok := s.repoFor(w, r, "") |
| 60 | if !ok { | 62 | if !ok { |
| @@ -75,7 +77,7 @@ func (s *Server) wiki(w http.ResponseWriter, r *http.Request) { | |||
| 75 | repoPage | 77 | repoPage |
| 76 | Page string | 78 | Page string |
| 77 | PageHTML template.HTML | 79 | PageHTML template.HTML |
| 78 | Pages []string | 80 | Nav []wikiNavGroup |
| 79 | Missing bool | 81 | Missing bool |
| 80 | }{repoPage: p, Missing: true}) | 82 | }{repoPage: p, Missing: true}) |
| 81 | return | 83 | return |
| @@ -97,15 +99,66 @@ func (s *Server) wiki(w http.ResponseWriter, r *http.Request) { | |||
| 97 | s.notFound(w, r) | 99 | s.notFound(w, r) |
| 98 | return | 100 | return |
| 99 | } | 101 | } |
| 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] }) | ||
| 101 | } | 115 | } |
| 102 | s.render(w, "wiki.html", struct { | 116 | s.render(w, "wiki.html", struct { |
| 103 | repoPage | 117 | repoPage |
| 104 | Page string | 118 | Page string |
| 105 | PageHTML template.HTML | 119 | PageHTML template.HTML |
| 106 | Pages []string | 120 | Nav []wikiNavGroup |
| 107 | Missing bool | 121 | 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. | ||
| 127 | type wikiNavGroup struct { | ||
| 128 | Dir string | ||
| 129 | Pages []wikiNavPage | ||
| 130 | } | ||
| 131 | |||
| 132 | type 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. | ||
| 139 | func 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 | ||
| 109 | } | 162 | } |
| 110 | 163 | ||
| 111 | // wikiRaw serves non-page files from the wiki (images referenced by pages). | 164 | // 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) { | |||
| 125 | s.notFound(w, r) | 178 | s.notFound(w, r) |
| 126 | return | 179 | return |
| 127 | } | 180 | } |
| 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) | ||
| 129 | w.Header().Set("X-Content-Type-Options", "nosniff") | 186 | w.Header().Set("X-Content-Type-Options", "nosniff") |
| 130 | w.Write(data) | 187 | w.Write(data) |
| 131 | } | 188 | } |
| 132 | 189 | ||
| 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. | ||
| 195 | func 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. | ||
| 211 | func 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 | |||
| 133 | // rewriteWikiLinks makes relative links resolve inside the wiki: page | 219 | // rewriteWikiLinks makes relative links resolve inside the wiki: page |
| 134 | // links (with or without .md/.org/.html extensions) go to /wiki/<page>, | 220 | // links (with or without .md/.org/.html extensions) go to /wiki/<page>, |
| 135 | // other relative targets (images) to the wiki raw route. | 221 | // other relative targets (images) to the wiki raw route. Targets resolve |
| 136 | func rewriteWikiLinks(rendered template.HTML, p repoPage) template.HTML { | 222 | // from the current page's folder, as wikiResolve describes. |
| 223 | func rewriteWikiLinks(rendered template.HTML, p repoPage, page string, isPage, isFile func(string) bool) template.HTML { | ||
| 137 | ctx := &html.Node{Type: html.ElementNode, Data: "div", DataAtom: atom.Div} | 224 | ctx := &html.Node{Type: html.ElementNode, Data: "div", DataAtom: atom.Div} |
| 138 | nodes, err := html.ParseFragment(strings.NewReader(string(rendered)), ctx) | 225 | nodes, err := html.ParseFragment(strings.NewReader(string(rendered)), ctx) |
| 139 | if err != nil { | 226 | if err != nil { |
| @@ -155,20 +242,15 @@ func rewriteWikiLinks(rendered template.HTML, p repoPage) template.HTML { | |||
| 155 | strings.HasPrefix(v, "data:") { | 242 | strings.HasPrefix(v, "data:") { |
| 156 | continue | 243 | continue |
| 157 | } | 244 | } |
| 158 | target := path.Clean(v) | ||
| 159 | if strings.HasPrefix(target, "..") { | ||
| 160 | continue | ||
| 161 | } | ||
| 162 | if isSrc { | 245 | 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 | } | ||
| 164 | continue | 249 | continue |
| 165 | } | 250 | } |
| 166 | ext := strings.ToLower(path.Ext(target)) | 251 | if target, ok := wikiResolve(page, trimPageExt(v), isPage); ok { |
| 167 | switch ext { | 252 | n.Attr[i].Val = base + "/" + target |
| 168 | case ".md", ".org", ".markdown", ".html": | ||
| 169 | target = strings.TrimSuffix(target, path.Ext(target)) | ||
| 170 | } | 253 | } |
| 171 | n.Attr[i].Val = base + "/" + target | ||
| 172 | } | 254 | } |
| 173 | } | 255 | } |
| 174 | for c := n.FirstChild; c != nil; c = c.NextSibling { | 256 | for c := n.FirstChild; c != nil; c = c.NextSibling { |
internal/httpd/wiki_test.go added +70
| @@ -0,0 +1,70 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "html/template" | ||
| 5 | "reflect" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | |||
| 9 | "gitbay.org/gitbay/internal/store" | ||
| 10 | ) | ||
| 11 | |||
| 12 | func 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 | |||
| 39 | func 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 | |||
| 60 | func 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: | |||
| 1748 | .wikipage { flex: 1; min-width: 0; } | 1748 | .wikipage { flex: 1; min-width: 0; } |
| 1749 | .wikinav { flex: none; width: 15rem; } | 1749 | .wikinav { flex: none; width: 15rem; } |
| 1750 | .wikinav ul { list-style: none; margin: var(--sp-1) 0 var(--sp-4); padding: 0; } | 1750 | .wikinav ul { list-style: none; margin: var(--sp-1) 0 var(--sp-4); padding: 0; } |
| 1751 | .wikinav .wikidir { margin-bottom: 0; } | ||
| 1751 | .wikinav li a { | 1752 | .wikinav li a { |
| 1752 | display: block; | 1753 | display: block; |
| 1753 | padding: 5px var(--sp-2); | 1754 | 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> | |||
| 13 | </div> | 13 | </div> |
| 14 | <nav class="wikinav"> | 14 | <nav class="wikinav"> |
| 15 | <p class="meta">pages</p> | 15 | <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}} | ||
| 17 | <p class="meta">edit under <code>.gitbay/wiki/</code> by push.</p> | 18 | <p class="meta">edit under <code>.gitbay/wiki/</code> by push.</p> |
| 18 | </nav> | 19 | </nav> |
| 19 | </div> | 20 | </div> |