Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 }