vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Give every account a profile page and a directory of accounts
a6b42433c087ab0c936e34a1d05cf488db7a92d6 · Keenan Nemetz · 8d ago · parent 727df386
Give every account a profile page and a directory of accounts Clicking a name now opens /u/<name>: the issues and changes somebody opened, the reviews they gave, the comments they left, and the repositories that work is in. The numbers and the feed are grouped reads of the same event rows the activity feed pages through, so a profile cannot disagree with the feed, and every read is limited to the repositories the reader may open. A private repository's work is not a number an outsider gets to learn, so two readers can see different totals and the page says what its numbers cover. /u lists the roster. Account names are public on this instance; their activity is not. Both pages ask crawlers to leave them out of an index. @name in an issue, a comment, a change request, or a release note links to the account it addresses, using the mention scanner CI already evaluates: what reads as an address on the page is what a workflow sees. A name that is not an account stays text, and code samples are skipped. Git author names are not linked and commit counts are not shown. A commit author is a name somebody typed, not the credential that proves an account, and the index only keeps the top eight authors of each default branch anyway.
server/web/markdown.go +107−2
| @@ -18,6 +18,8 @@ import ( | |||
| 18 | 18 | "github.com/yuin/goldmark/ast" | |
| 19 | 19 | "github.com/yuin/goldmark/extension" | |
| 20 | 20 | "github.com/yuin/goldmark/text" | |
| 21 | |||
| 22 | "github.com/rodent-software/vole/metadata" | ||
| 21 | 23 | ) | |
| 22 | 24 | ||
| 23 | 25 | var ( | |
| @@ -39,6 +41,12 @@ type treeDoc struct { | |||
| 39 | 41 | Repo string | |
| 40 | 42 | Ref string | |
| 41 | 43 | Path string | |
| 44 | // Mentions links @name to the account it addresses. It is set for the | ||
| 45 | // documents that address people — an issue, a comment, a change request, a | ||
| 46 | // release note — and left off for a README or a documentation file, where a | ||
| 47 | // name is an example rather than somebody being spoken to. The same bodies | ||
| 48 | // are the ones CI evaluates mentions in. | ||
| 49 | Mentions bool | ||
| 42 | 50 | } | |
| 43 | 51 | ||
| 44 | 52 | // renderMarkdown converts untrusted commit content into HTML that is safe to put | |
| @@ -47,9 +55,11 @@ type treeDoc struct { | |||
| 47 | 55 | // | |
| 48 | 56 | // `#<ulid>` becomes a link to the issue. That is done on the markdown source | |
| 49 | 57 | // rather than on the HTML, and fenced code blocks are skipped so a reference | |
| 50 | // inside a code sample stays literal. | ||
| 58 | // inside a code sample stays literal. `@name` becomes a link to the account's | ||
| 59 | // profile on the same terms: the mention scanner CI uses decides what counts, so | ||
| 60 | // the page and a workflow agree about who was addressed. | ||
| 51 | 61 | func (w *Web) renderMarkdown(repoName, src string) template.HTML { | |
| 52 | return w.renderTreeMarkdown(treeDoc{Repo: repoName}, src) | ||
| 62 | return w.renderTreeMarkdown(treeDoc{Repo: repoName, Mentions: true}, src) | ||
| 53 | 63 | } | |
| 54 | 64 | ||
| 55 | 65 | // renderTreeMarkdown is renderMarkdown for a document that has a place in the | |
| @@ -62,6 +72,9 @@ func (w *Web) renderTreeMarkdown(doc treeDoc, src string) template.HTML { | |||
| 62 | 72 | source := []byte(src) | |
| 63 | 73 | parsed := markdown.Parser().Parse(text.NewReader(source)) | |
| 64 | 74 | rewriteTreeLinks(parsed, doc) | |
| 75 | if doc.Mentions { | ||
| 76 | w.linkMentions(parsed, source) | ||
| 77 | } | ||
| 65 | 78 | var buf bytes.Buffer | |
| 66 | 79 | if err := markdown.Renderer().Render(&buf, source, parsed); err != nil { | |
| 67 | 80 | return template.HTML(template.HTMLEscapeString(src)) | |
| @@ -187,3 +200,95 @@ func isMarkdown(path string) bool { | |||
| 187 | 200 | } | |
| 188 | 201 | return false | |
| 189 | 202 | } | |
| 203 | |||
| 204 | // linkMentions turns `@name` into a link to the account's profile. It works on | ||
| 205 | // the parsed tree rather than on the source text so that everything goldmark | ||
| 206 | // already understands stays correct: a mention inside inline code, a fenced | ||
| 207 | // block, an image, or another link's label is left exactly as written, and a | ||
| 208 | // backslash escape is still visible in the raw segment and therefore still | ||
| 209 | // literal. | ||
| 210 | // | ||
| 211 | // Only names that belong to an account become links. A typo, or a name that | ||
| 212 | // addresses nobody, stays text: a profile link that 404s is worse than a mention | ||
| 213 | // that does not open anything. | ||
| 214 | func (w *Web) linkMentions(doc ast.Node, source []byte) { | ||
| 215 | var nodes []*ast.Text | ||
| 216 | ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { | ||
| 217 | if !entering { | ||
| 218 | return ast.WalkContinue, nil | ||
| 219 | } | ||
| 220 | t, ok := n.(*ast.Text) | ||
| 221 | if !ok || t.IsRaw() || !mentionProse(n) { | ||
| 222 | return ast.WalkContinue, nil | ||
| 223 | } | ||
| 224 | nodes = append(nodes, t) | ||
| 225 | return ast.WalkContinue, nil | ||
| 226 | }) | ||
| 227 | for _, node := range nodes { | ||
| 228 | w.linkMentionNode(node, source) | ||
| 229 | } | ||
| 230 | } | ||
| 231 | |||
| 232 | // mentionProse reports whether a text node is prose a mention may be linked in: | ||
| 233 | // not the inside of code, a link, an autolink, or an image. | ||
| 234 | func mentionProse(n ast.Node) bool { | ||
| 235 | for p := n.Parent(); p != nil; p = p.Parent() { | ||
| 236 | switch p.(type) { | ||
| 237 | case *ast.Link, *ast.AutoLink, *ast.Image, *ast.CodeSpan, *ast.CodeBlock, *ast.FencedCodeBlock: | ||
| 238 | return false | ||
| 239 | } | ||
| 240 | } | ||
| 241 | return true | ||
| 242 | } | ||
| 243 | |||
| 244 | // linkMentionNode rewrites one text node into prose and links. The spans come from | ||
| 245 | // metadata.EachMention, which is the scanner the trigger server evaluates, so | ||
| 246 | // what the page links and what wakes a workflow cannot drift apart. | ||
| 247 | func (w *Web) linkMentionNode(node *ast.Text, source []byte) { | ||
| 248 | raw := node.Segment.Value(source) | ||
| 249 | type mention struct { | ||
| 250 | start, end int // byte offsets into raw, the '@' included | ||
| 251 | href string | ||
| 252 | } | ||
| 253 | var found []mention | ||
| 254 | metadata.EachMention(string(raw), func(start, end int, name string) bool { | ||
| 255 | ref := w.userRef(name) | ||
| 256 | if !ref.Known { | ||
| 257 | return true | ||
| 258 | } | ||
| 259 | found = append(found, mention{start: start, end: end, href: ref.Href}) | ||
| 260 | return true | ||
| 261 | }) | ||
| 262 | if len(found) == 0 { | ||
| 263 | return | ||
| 264 | } | ||
| 265 | |||
| 266 | base := node.Segment.Start | ||
| 267 | var replacement []ast.Node | ||
| 268 | last := node.Segment.Start | ||
| 269 | for _, m := range found { | ||
| 270 | start, end := base+m.start, base+m.end | ||
| 271 | if start > last { | ||
| 272 | replacement = append(replacement, ast.NewTextSegment(text.NewSegment(last, start))) | ||
| 273 | } | ||
| 274 | link := ast.NewLink() | ||
| 275 | link.Destination = []byte(m.href) | ||
| 276 | link.AppendChild(link, ast.NewTextSegment(text.NewSegment(start, end))) | ||
| 277 | replacement = append(replacement, link) | ||
| 278 | last = end | ||
| 279 | } | ||
| 280 | if last < node.Segment.Stop { | ||
| 281 | replacement = append(replacement, ast.NewTextSegment(text.NewSegment(last, node.Segment.Stop))) | ||
| 282 | } | ||
| 283 | if len(replacement) == 0 { | ||
| 284 | return | ||
| 285 | } | ||
| 286 | |||
| 287 | parent := node.Parent() | ||
| 288 | parent.ReplaceChild(parent, node, replacement[0]) | ||
| 289 | cursor := replacement[0] | ||
| 290 | for _, n := range replacement[1:] { | ||
| 291 | parent.InsertAfter(parent, cursor, n) | ||
| 292 | cursor = n | ||
| 293 | } | ||
| 294 | } | ||