A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 9b6ae6d866

9b6ae6d86683b8e4918ba15c08b318a25eea1709

parent: 71e32809de

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-25T01:05:18Z

Add wikis as push-edited companion repositories

Closes #25

ssh://host/owner/name.wiki.git serves a companion bare repo: access is
exactly the parent repo's (read to clone, write to push; deploy keys
excluded; archived repos read-only; 404-parity for private parents),
created on first push with no hooks — wikis carry no ref policy, no
MRs, no signature requirements. The web renders pages through the same
sanitized pipeline as READMEs at /owner/repo/wiki (Home resolution,
.md/.org fallbacks, page sidebar, relative links rewritten to wiki
pages and images to a wiki raw route); the tab appears once the wiki
exists. Repo names ending .wiki are reserved; delete and transfer
carry the companion.
docs/users.org +13
@@ -192,6 +192,19 @@ that is why no =owner/name= appears above. Anywhere else, pass it as the
192192 first argument. Long text: =--body= inline, =--file -= from stdin, or
193193 neither on a terminal and =$EDITOR= opens.
194194
195Wikis are companion repositories edited by push — no separate storage,
196no web editor, the same rendering pipeline as READMEs (markdown and
197org). Access mirrors the parent repo (read to view, write to push);
198the companion is created on your first push and follows the repo
199through transfer and delete:
200
201#+begin_src sh
202git clone ssh://git@<host>/you/project.wiki.git
203# add Home.md (or .org), more pages, images; relative links between
204# pages just work on the web at /you/project/wiki
205git push
206#+end_src
207
195208 Releases anchor notes and binary assets to a pushed tag (write access;
196209 assets stream over SSH, capped by the instance's =max_asset_bytes=):
197210
e2e/wiki_test.go added +101
@@ -0,0 +1,101 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10func TestWikis(t *testing.T) {
11 inst := startInstance(t)
12 aliceKey := inst.newKey(t, "alice")
13 bobKey := inst.newKey(t, "bob")
14 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
15 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
16 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
17 t.Fatal("repo create failed")
18 }
19 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secretive", "--private"); code != 0 {
20 t.Fatal("private repo create failed")
21 }
22
23 // No wiki yet: the tab is absent, the page shows the push hint, and
24 // cloning the companion says so.
25 _, body := inst.get(t, "/alice/app")
26 if strings.Contains(body, ">wiki<") {
27 t.Fatal("wiki tab shown with no wiki")
28 }
29 _, body = inst.get(t, "/alice/app/wiki")
30 if !strings.Contains(body, "no wiki yet") {
31 t.Fatal("missing-wiki hint absent")
32 }
33 env := inst.gitEnv(aliceKey)
34 if out, code := gitRun(t, t.TempDir(), env, "clone", inst.sshURL("alice/app.wiki"), "w"); code == 0 || !strings.Contains(out, "no wiki yet") {
35 t.Fatalf("clone of absent wiki: %d\n%s", code, out)
36 }
37
38 // First push creates the wiki. A reader without write cannot push it.
39 work := t.TempDir()
40 mustGit(t, work, env, "init", "-q", "-b", "main", "w")
41 dir := filepath.Join(work, "w")
42 os.WriteFile(filepath.Join(dir, "Home.md"), []byte(
43 "# welcome\n\nsee [Setup](Setup.md) and ![shot](shot.png)\n"), 0o644)
44 os.WriteFile(filepath.Join(dir, "Setup.org"), []byte("* setup\n\nsteps here\n"), 0o644)
45 os.WriteFile(filepath.Join(dir, "shot.png"), []byte{0x89, 0x50, 0x4e, 0x47}, 0o644)
46 mustGit(t, dir, env, "add", ".")
47 mustGit(t, dir, env, "commit", "-q", "-m", "wiki start")
48 mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/app.wiki"), "main")
49
50 benv := inst.gitEnv(bobKey)
51 if out, code := gitRun(t, dir, benv, "push", inst.sshURL("alice/app.wiki"), "main"); code == 0 && !strings.Contains(out, "denied") {
52 t.Fatalf("reader pushed the wiki: %d\n%s", code, out)
53 }
54
55 // Rendering: home resolves, tab appears, links rewrite to wiki pages
56 // and images to the wiki raw route; org pages render too.
57 _, body = inst.get(t, "/alice/app")
58 if !strings.Contains(body, ">wiki<") {
59 t.Fatal("wiki tab missing after push")
60 }
61 _, body = inst.get(t, "/alice/app/wiki")
62 if !strings.Contains(body, "welcome") ||
63 !strings.Contains(body, `href="/alice/app/wiki/Setup"`) ||
64 !strings.Contains(body, `src="/alice/app/wiki/_raw/shot.png"`) {
65 t.Fatalf("wiki home rendering:\n%s", body)
66 }
67 _, body = inst.get(t, "/alice/app/wiki/Setup")
68 if !strings.Contains(body, "steps here") {
69 t.Fatal("org wiki page missing")
70 }
71 if status, _ := inst.get(t, "/alice/app/wiki/Nope"); status != 404 {
72 t.Fatalf("missing page: %d", status)
73 }
74 // The raw route serves the image bytes.
75 status, raw := inst.get(t, "/alice/app/wiki/_raw/shot.png")
76 if status != 200 || !strings.HasPrefix(raw, "\x89PNG") {
77 t.Fatalf("wiki raw: %d", status)
78 }
79
80 // 404-parity: a private repo's wiki is invisible, over web and git.
81 mustGit(t, dir, env, "push", "-q", inst.sshURL("alice/secretive.wiki"), "main")
82 if status, _ := inst.get(t, "/alice/secretive/wiki"); status != 404 {
83 t.Fatalf("private wiki page: %d", status)
84 }
85 if out, code := gitRun(t, t.TempDir(), benv, "clone", inst.sshURL("alice/secretive.wiki"), "x"); code == 0 || !strings.Contains(out, "not found") {
86 t.Fatalf("private wiki clone by outsider: %d\n%s", code, out)
87 }
88
89 // Repo names ending .wiki are refused (companion namespace).
90 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/notes.wiki"); code != 2 || !strings.Contains(errOut, "reserved") {
91 t.Fatalf(".wiki name allowed: %d %s", code, errOut)
92 }
93
94 // Deleting the repo removes the wiki companion.
95 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "delete", "alice/secretive", "--yes"); code != 0 {
96 t.Fatal("repo delete failed")
97 }
98 if _, err := os.Stat(filepath.Join(inst.root, "repos", "alice", "secretive.wiki.git")); err == nil {
99 t.Fatal("wiki survived repo delete")
100 }
101}
internal/control/repo.go +6
@@ -307,6 +307,11 @@ func runRepoTransfer(c *Ctx, args []string) int {
307307 c.Store.TransferRepo(repo.ID, repo.OwnerKind, repo.OwnerID)
308308 return c.fail(protocol.ExitFailure, "moving repository: %v", err)
309309 }
310 // The wiki companion follows its repo.
311 oldWiki := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name+".wiki")
312 if _, err := os.Stat(oldWiki); err == nil {
313 os.Rename(oldWiki, RepoDir(c.Cfg.Server.Root, newOwner, repo.Name+".wiki"))
314 }
310315 newPath := newOwner + "/" + repo.Name
311316 return c.emit(map[string]string{"repo": newPath, "was": repo.Path()}, func(w io.Writer) {
312317 fmt.Fprintf(w, "transferred %s to %s — clone URLs now use %s\n", repo.Path(), newPath, newPath)
@@ -346,6 +351,7 @@ func runRepoDelete(c *Ctx, args []string) int {
346351 if err := os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)); err != nil {
347352 return c.fail(protocol.ExitFailure, "database row removed but disk cleanup failed: %v", err)
348353 }
354 os.RemoveAll(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name+".wiki"))
349355 return c.emit(map[string]string{"deleted": repo.Path()}, func(w io.Writer) {
350356 fmt.Fprintf(w, "deleted %s\n", repo.Path())
351357 })
internal/gitutil/gitutil.go +7 −3
@@ -22,9 +22,13 @@ func InitBare(path, defaultBranch, hooksPath string) error {
2222 if out, err := cmd.CombinedOutput(); err != nil {
2323 return fmt.Errorf("git init: %v\n%s", err, out)
2424 }
25 cmd = exec.Command("git", "-C", path, "config", "core.hooksPath", hooksPath)
26 if out, err := cmd.CombinedOutput(); err != nil {
27 return fmt.Errorf("git config core.hooksPath: %v\n%s", err, out)
25 // An empty hooksPath leaves the bare repo with no hooks — used for
26 // companion repos (wikis) that carry no ref policy.
27 if hooksPath != "" {
28 cmd = exec.Command("git", "-C", path, "config", "core.hooksPath", hooksPath)
29 if out, err := cmd.CombinedOutput(); err != nil {
30 return fmt.Errorf("git config core.hooksPath: %v\n%s", err, out)
31 }
2832 }
2933 return nil
3034 }
internal/httpd/routes.go +3
@@ -43,6 +43,9 @@ func (s *Server) Routes() []Route {
4343 Route{Method: "GET", Pattern: "/{owner}/{repo}/blame/{ref}/{path...}", Handler: s.blame},
4444 Route{Method: "GET", Pattern: "/{owner}/{repo}/search", Handler: s.search},
4545 Route{Method: "GET", Pattern: "/{owner}/{repo}/milestones", Handler: s.milestones},
46 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki", Handler: s.wiki},
47 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/_raw/{path...}", Handler: s.wikiRaw},
48 Route{Method: "GET", Pattern: "/{owner}/{repo}/wiki/{page}", Handler: s.wiki},
4649 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases", Handler: s.releases},
4750 Route{Method: "GET", Pattern: "/{owner}/{repo}/releases/download/{tag}/{name}", Handler: s.releaseAsset},
4851 Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw},
internal/httpd/web.go +4
@@ -216,6 +216,8 @@ type repoPage struct {
216216 Tab string // active tab in the repo header
217217 Topics []string
218218 Pinned bool // by the viewer
219 HasWiki bool
220 Host string
219221 }
220222
221223 // repoFor resolves the repo for a web request; false means 404 was sent.
@@ -253,6 +255,8 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
253255 Site: s.siteName(),
254256 Viewer: viewer.Username,
255257 Pinned: pinned,
258 HasWiki: s.wikiDir(repo.OwnerName, repo.Name) != "",
259 Host: s.cfg.SiteHost(),
256260 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
257261 Repo: repo,
258262 Ref: ref,
internal/httpd/wiki.go added +182
@@ -0,0 +1,182 @@
1package httpd
2
3import (
4 "html/template"
5 "net/http"
6 "os"
7 "path"
8 "strings"
9
10 "golang.org/x/net/html"
11 "golang.org/x/net/html/atom"
12
13 "gitbay.org/gitbay/internal/control"
14 "gitbay.org/gitbay/internal/gitutil"
15)
16
17// wikiDir returns the companion repo path, or "" when the repo has none.
18func (s *Server) wikiDir(owner, name string) string {
19 dir := control.RepoDir(s.cfg.Server.Root, owner, name+".wiki")
20 if _, err := os.Stat(dir); err != nil {
21 return ""
22 }
23 return dir
24}
25
26// wiki renders a page from the repo's wiki companion. The home page is
27// Home.<ext> (or README.<ext>); /wiki/<name> resolves <name> with .md and
28// .org fallbacks. Rendering reuses the same sanitized pipeline as READMEs.
29func (s *Server) wiki(w http.ResponseWriter, r *http.Request) {
30 p, ok := s.repoFor(w, r, "")
31 if !ok {
32 return
33 }
34 p.Tab = "wiki"
35 dir := s.wikiDir(p.Repo.OwnerName, p.Repo.Name)
36 if dir == "" {
37 s.render(w, "wiki.html", struct {
38 repoPage
39 Page string
40 PageHTML template.HTML
41 Pages []string
42 Missing bool
43 }{repoPage: p, Missing: true})
44 return
45 }
46 entries, err := gitutil.ListTree(dir, "main", "")
47 if err != nil { // wiki repo exists but has no commits yet
48 s.render(w, "wiki.html", struct {
49 repoPage
50 Page string
51 PageHTML template.HTML
52 Pages []string
53 Missing bool
54 }{repoPage: p, Missing: true})
55 return
56 }
57 var pages []string
58 for _, e := range entries {
59 if e.Type != "blob" {
60 continue
61 }
62 ext := strings.ToLower(path.Ext(e.Name))
63 if ext == ".md" || ext == ".org" || ext == ".markdown" {
64 pages = append(pages, strings.TrimSuffix(e.Name, path.Ext(e.Name)))
65 }
66 }
67
68 page := strings.Trim(r.PathValue("page"), "/")
69 if page == "" {
70 for _, home := range []string{"Home", "home", "README", "index"} {
71 for _, pg := range pages {
72 if pg == home {
73 page = home
74 }
75 }
76 if page != "" {
77 break
78 }
79 }
80 if page == "" && len(pages) > 0 {
81 page = pages[0]
82 }
83 }
84 var pageHTML template.HTML
85 if page != "" {
86 fileName, raw := "", []byte(nil)
87 for _, ext := range []string{".md", ".org", ".markdown"} {
88 if b, err := gitutil.ReadBlob(dir, "main", page+ext, maxRenderBytes); err == nil {
89 fileName, raw = page+ext, b
90 break
91 }
92 }
93 if fileName == "" {
94 s.notFound(w, r)
95 return
96 }
97 pageHTML = rewriteWikiLinks(renderReadme(fileName, raw), p)
98 }
99 s.render(w, "wiki.html", struct {
100 repoPage
101 Page string
102 PageHTML template.HTML
103 Pages []string
104 Missing bool
105 }{p, page, pageHTML, pages, false})
106}
107
108// wikiRaw serves non-page files from the wiki (images referenced by pages).
109func (s *Server) wikiRaw(w http.ResponseWriter, r *http.Request) {
110 p, ok := s.repoFor(w, r, "")
111 if !ok {
112 return
113 }
114 dir := s.wikiDir(p.Repo.OwnerName, p.Repo.Name)
115 if dir == "" {
116 s.notFound(w, r)
117 return
118 }
119 data, err := gitutil.ReadBlob(dir, "main", strings.Trim(r.PathValue("path"), "/"), s.cfg.Limits.MaxBlobBytes)
120 if err != nil {
121 s.notFound(w, r)
122 return
123 }
124 w.Header().Set("Content-Type", "application/octet-stream")
125 w.Header().Set("X-Content-Type-Options", "nosniff")
126 w.Write(data)
127}
128
129// rewriteWikiLinks makes relative links resolve inside the wiki: page
130// links (with or without .md/.org/.html extensions) go to /wiki/<page>,
131// other relative targets (images) to the wiki raw route.
132func rewriteWikiLinks(rendered template.HTML, p repoPage) template.HTML {
133 ctx := &html.Node{Type: html.ElementNode, Data: "div", DataAtom: atom.Div}
134 nodes, err := html.ParseFragment(strings.NewReader(string(rendered)), ctx)
135 if err != nil {
136 return rendered
137 }
138 base := "/" + p.Repo.Path() + "/wiki"
139 var walk func(*html.Node)
140 walk = func(n *html.Node) {
141 if n.Type == html.ElementNode {
142 for i, a := range n.Attr {
143 isHref := a.Key == "href" && n.Data == "a"
144 isSrc := a.Key == "src" && (n.Data == "img" || n.Data == "video" || n.Data == "source")
145 if !isHref && !isSrc {
146 continue
147 }
148 v := a.Val
149 if v == "" || strings.Contains(v, "://") || strings.HasPrefix(v, "/") ||
150 strings.HasPrefix(v, "#") || strings.HasPrefix(v, "mailto:") ||
151 strings.HasPrefix(v, "data:") {
152 continue
153 }
154 target := path.Clean(v)
155 if strings.HasPrefix(target, "..") {
156 continue
157 }
158 if isSrc {
159 n.Attr[i].Val = base + "/_raw/" + target
160 continue
161 }
162 ext := strings.ToLower(path.Ext(target))
163 switch ext {
164 case ".md", ".org", ".markdown", ".html":
165 target = strings.TrimSuffix(target, path.Ext(target))
166 }
167 n.Attr[i].Val = base + "/" + target
168 }
169 }
170 for c := n.FirstChild; c != nil; c = c.NextSibling {
171 walk(c)
172 }
173 }
174 var out strings.Builder
175 for _, n := range nodes {
176 walk(n)
177 if err := html.Render(&out, n); err != nil {
178 return rendered
179 }
180 }
181 return template.HTML(out.String())
182}
internal/policy/names.go +3
@@ -55,6 +55,9 @@ func ValidateName(name string) error {
5555 if len(name) > 4 && name[len(name)-4:] == ".git" {
5656 return fmt.Errorf("invalid name %q: must not end in .git", name)
5757 }
58 if len(name) > 5 && name[len(name)-5:] == ".wiki" {
59 return fmt.Errorf("invalid name %q: .wiki names are reserved for wiki companion repositories", name)
60 }
5861 return nil
5962 }
6063
internal/sshd/sshd.go +60
@@ -15,6 +15,7 @@ import (
1515 "os"
1616 "path/filepath"
1717 "strconv"
18 "strings"
1819 "time"
1920
2021 "golang.org/x/crypto/ssh"
@@ -284,6 +285,12 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a
284285 }
285286 write := service == "git-receive-pack"
286287
288 // Wiki companion repos: ssh://.../owner/name.wiki.git. Access derives
289 // from the parent repo; the bare repo is created on first push.
290 if base, ok := strings.CutSuffix(strings.TrimSuffix(argv[1], ".git"), ".wiki"); ok {
291 return runWikiGit(cfg, st, user, scope, service, base, write, stdin, stdout, stderr)
292 }
293
287294 repo, err := st.RepoByPath(argv[1])
288295 if err != nil {
289296 fmt.Fprintln(stderr, "repository not found")
@@ -339,3 +346,56 @@ func runGit(cfg config.Config, st *store.Store, user store.User, scope string, a
339346 }
340347 return protocol.ExitOK
341348 }
349
350// runWikiGit serves a repo's wiki companion. The wiki carries no ref
351// policy (no hooks, no merge requests); access is exactly the parent
352// repo's, and the bare repo is created on the first push. Deploy keys —
353// bound to the repo's own git data for CI — cannot touch the wiki.
354func runWikiGit(cfg config.Config, st *store.Store, user store.User, scope, service, basePath string,
355 write bool, stdin io.Reader, stdout, stderr io.Writer) int {
356 repo, err := st.RepoByPath(basePath)
357 if err != nil {
358 fmt.Fprintln(stderr, "repository not found")
359 return protocol.ExitNotFound
360 }
361 if policy.IsDeployScope(scope) {
362 fmt.Fprintln(stderr, "repository not found")
363 return protocol.ExitNotFound
364 }
365 grant, err := st.AccessRole(repo.ID, user.ID)
366 if err != nil {
367 fmt.Fprintln(stderr, "internal error")
368 return protocol.ExitFailure
369 }
370 if !policy.CanRead(user, repo, grant) {
371 fmt.Fprintln(stderr, "repository not found")
372 return protocol.ExitNotFound
373 }
374 if !policy.ScopeAllowsGit(scope, repo.Path(), write) {
375 fmt.Fprintf(stderr, "this key's scope (%s) does not allow %s on the wiki\n", scope, service)
376 return protocol.ExitDenied
377 }
378 if write && !policy.CanWrite(user, repo, grant) {
379 fmt.Fprintf(stderr, "write access to %s wiki denied\n", repo.Path())
380 return protocol.ExitDenied
381 }
382 if write && repo.Settings.Archived {
383 fmt.Fprintf(stderr, "%s is archived and read-only\n", repo.Path())
384 return protocol.ExitDenied
385 }
386 dir := control.RepoDir(cfg.Server.Root, repo.OwnerName, repo.Name+".wiki")
387 if _, err := os.Stat(dir); err != nil {
388 if !write {
389 fmt.Fprintln(stderr, "this repository has no wiki yet")
390 return protocol.ExitNotFound
391 }
392 if err := gitutil.InitBare(dir, "main", ""); err != nil {
393 fmt.Fprintln(stderr, "initializing wiki failed")
394 return protocol.ExitFailure
395 }
396 }
397 if err := gitutil.Transport(service, dir, stdin, stdout, stderr, nil, cfg.Limits.MaxPackBytes); err != nil {
398 return protocol.ExitFailure
399 }
400 return protocol.ExitOK
401}
internal/web/static/style.css +13
@@ -738,6 +738,19 @@ pre.matchline mark {
738738 border-radius: 2px;
739739 }
740740
741/* wiki */
742.wikilayout { display: flex; gap: var(--sp-5); align-items: flex-start; }
743.wikipage { flex: 1; min-width: 0; }
744.wikinav { flex: none; width: 13rem; padding-top: var(--sp-4); }
745.wikinav ul { list-style: none; margin: var(--sp-1) 0 var(--sp-4); padding: 0; }
746.wikinav li a { display: block; padding: 0.15rem 0; color: var(--fg); }
747.wikinav li a:hover { color: var(--accent); text-decoration: none; }
748.wikinav li a.active { color: var(--accent); font-weight: 550; }
749@media (max-width: 40rem) {
750 .wikilayout { flex-direction: column; }
751 .wikinav { width: auto; padding-top: 0; }
752}
753
741754 /* blame */
742755 .blame {
743756 border: 1px solid var(--line);
internal/web/templates/layout.html +2 −1
@@ -39,7 +39,8 @@
3939 <a {{if eq .Tab "releases"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/releases">releases</a>
4040 <a {{if eq .Tab "issues"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues">issues</a>
4141 <a {{if eq .Tab "merge requests"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs">merge requests</a>
42 <a {{if eq .Tab "search"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search">search</a>
42 {{if .HasWiki}}<a {{if eq .Tab "wiki"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/wiki">wiki</a>
43 {{end}}<a {{if eq .Tab "search"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search">search</a>
4344 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">archive</a>
4445 </nav>
4546 </div>
internal/web/templates/wiki.html added +21
@@ -0,0 +1,21 @@
1{{define "title"}}wiki{{if .Page}}: {{.Page}}{{end}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}}
3{{template "repoheader" .}}
4{{if .Missing}}<p class="empty-note">no wiki yet — create one by pushing pages:<br>
5<code>git clone ssh://git@{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}.wiki.git</code> then add <code>Home.md</code> (or .org) and push.</p>
6{{else}}
7<div class="wikilayout">
8<div class="wikipage">
9<section class="readme">
10<div class="cardhead">{{.Page}}</div>
11<div class="rendered">{{.PageHTML}}</div>
12</section>
13</div>
14<nav class="wikinav">
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>
17<p class="meta">edit by push:<br><code>{{.Repo.Path}}.wiki.git</code></p>
18</nav>
19</div>
20{{end}}
21{{end}}