Commit 84c6f81258
Verified · cmc ci/build: success ci/test: success
.gitbay/wiki/Users.org +7
| @@ -344,6 +344,13 @@ mkdir -p .gitbay/wiki | ||
| 344 | 344 | git add .gitbay/wiki && git commit -m "wiki" && git push |
| 345 | 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 | 354 | Releases anchor notes and binary assets to a pushed tag (write access; |
| 348 | 355 | assets stream over SSH, capped by the instance's =max_asset_bytes=). |
| 349 | 356 | While a release exists its tag can be neither deleted nor moved, whether |
e2e/wiki_test.go +44
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | package e2e |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "net/http" | |
| 4 | 5 | "os" |
| 5 | 6 | "path/filepath" |
| 6 | 7 | "strings" |
| @@ -90,6 +91,46 @@ func TestWikis(t *testing.T) { | ||
| 90 | 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 | 134 | // A wiki is readable from every surface, not just a browser: the |
| 94 | 135 | // commands are what the web dispatches, and what the CLI and the |
| 95 | 136 | // JSON API reach. |
| @@ -100,6 +141,9 @@ func TestWikis(t *testing.T) { | ||
| 100 | 141 | if !strings.Contains(out, `"Home"`) || !strings.Contains(out, `"Setup"`) { |
| 101 | 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 | 147 | if !strings.Contains(out, `"home":"Home"`) { |
| 104 | 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 | 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 | 62 | func wikiPages(dir, branch string) []string { |
| 62 | entries, err := gitutil.ListTree(dir, branch, wikiTreePath) | |
| 63 | entries, err := gitutil.ListTreeRecursive(dir, branch, wikiTreePath) | |
| 63 | 64 | if err != nil { |
| 64 | 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 | 25 | // ListTree lists one level of the tree at ref:path. |
| 26 | 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 | 37 | spec := ref |
| 28 | 38 | if path != "" { |
| 29 | 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 | 46 | out, err := cmd.Output() |
| 33 | 47 | if err != nil { |
| 34 | 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 | 69 | Route{Method: "GET", Pattern: "/{owner}/{repo}/labels", Handler: s.labels}, |
| 70 | 70 | Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki", Handler: s.wiki}, |
| 71 | 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 | 73 | Route{Method: "GET", Pattern: "/{owner}/{repo}/releases", Handler: s.releases}, |
| 74 | 74 | Route{Method: "GET", Pattern: "/{owner}/{repo}/releases.atom", Handler: s.releasesAtom}, |
| 75 | 75 | Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom", Handler: s.logAtom}, |
internal/httpd/wiki.go +101 −19
| @@ -4,6 +4,7 @@ import ( | ||
| 4 | 4 | "html/template" |
| 5 | 5 | "net/http" |
| 6 | 6 | "path" |
| 7 | "sort" | |
| 7 | 8 | "strings" |
| 8 | 9 | |
| 9 | 10 | "golang.org/x/net/html" |
| @@ -29,7 +30,7 @@ func (s *Server) wikiDir(repo store.Repo) (dir, branch string) { | ||
| 29 | 30 | // hasWiki reports whether repo's default branch holds a wiki page. |
| 30 | 31 | func (s *Server) hasWiki(repo store.Repo) bool { |
| 31 | 32 | dir, branch := s.wikiDir(repo) |
| 32 | entries, err := gitutil.ListTree(dir, branch, wikiTreePath) | |
| 33 | entries, err := gitutil.ListTreeRecursive(dir, branch, wikiTreePath) | |
| 33 | 34 | if err != nil { |
| 34 | 35 | return false |
| 35 | 36 | } |
| @@ -54,7 +55,8 @@ func wikiExtMatch(name string) bool { | ||
| 54 | 55 | |
| 55 | 56 | // wiki renders a page from the repo's .gitbay/wiki tree. The home page is |
| 56 | 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 | 60 | func (s *Server) wiki(w http.ResponseWriter, r *http.Request) { |
| 59 | 61 | p, ok := s.repoFor(w, r, "") |
| 60 | 62 | if !ok { |
| @@ -75,7 +77,7 @@ func (s *Server) wiki(w http.ResponseWriter, r *http.Request) { | ||
| 75 | 77 | repoPage |
| 76 | 78 | Page string |
| 77 | 79 | PageHTML template.HTML |
| 78 | Pages []string | |
| 80 | Nav []wikiNavGroup | |
| 79 | 81 | Missing bool |
| 80 | 82 | }{repoPage: p, Missing: true}) |
| 81 | 83 | return |
| @@ -97,15 +99,66 @@ func (s *Server) wiki(w http.ResponseWriter, r *http.Request) { | ||
| 97 | 99 | s.notFound(w, r) |
| 98 | 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 | 116 | s.render(w, "wiki.html", struct { |
| 103 | 117 | repoPage |
| 104 | 118 | Page string |
| 105 | 119 | PageHTML template.HTML |
| 106 | Pages []string | |
| 120 | Nav []wikiNavGroup | |
| 107 | 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 | 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 | 178 | s.notFound(w, r) |
| 126 | 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 | 186 | w.Header().Set("X-Content-Type-Options", "nosniff") |
| 130 | 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 | 219 | // rewriteWikiLinks makes relative links resolve inside the wiki: page |
| 134 | 220 | // links (with or without .md/.org/.html extensions) go to /wiki/<page>, |
| 135 | // other relative targets (images) to the wiki raw route. | |
| 136 | func 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. | |
| 223 | func rewriteWikiLinks(rendered template.HTML, p repoPage, page string, isPage, isFile func(string) bool) template.HTML { | |
| 137 | 224 | ctx := &html.Node{Type: html.ElementNode, Data: "div", DataAtom: atom.Div} |
| 138 | 225 | nodes, err := html.ParseFragment(strings.NewReader(string(rendered)), ctx) |
| 139 | 226 | if err != nil { |
| @@ -155,20 +242,15 @@ func rewriteWikiLinks(rendered template.HTML, p repoPage) template.HTML { | ||
| 155 | 242 | strings.HasPrefix(v, "data:") { |
| 156 | 243 | continue |
| 157 | 244 | } |
| 158 | target := path.Clean(v) | |
| 159 | if strings.HasPrefix(target, "..") { | |
| 160 | continue | |
| 161 | } | |
| 162 | 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 | 249 | continue |
| 165 | 250 | } |
| 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 | |
| 170 | 253 | } |
| 171 | n.Attr[i].Val = base + "/" + target | |
| 172 | 254 | } |
| 173 | 255 | } |
| 174 | 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 | 1748 | .wikipage { flex: 1; min-width: 0; } |
| 1749 | 1749 | .wikinav { flex: none; width: 15rem; } |
| 1750 | 1750 | .wikinav ul { list-style: none; margin: var(--sp-1) 0 var(--sp-4); padding: 0; } |
| 1751 | .wikinav .wikidir { margin-bottom: 0; } | |
| 1751 | 1752 | .wikinav li a { |
| 1752 | 1753 | display: block; |
| 1753 | 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 | 13 | </div> |
| 14 | 14 | <nav class="wikinav"> |
| 15 | 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 | 18 | <p class="meta">edit under <code>.gitbay/wiki/</code> by push.</p> |
| 18 | 19 | </nav> |
| 19 | 20 | </div> |