A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit b34003973f

b34003973f11dbbd96de409f68b26514798fe405

parent: 26b59e4e18

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24T16:42:23Z

Add autolink package for cross-references and mentions

Parser-based HTML rewriting: #N and !N same-repo refs, owner/name#N
cross-repo refs, @user mentions. Walks text nodes with x/net/html so
content inside a, code, and pre is never touched; only targets the
Resolver confirms become links. Wiring into the web handlers follows
separately.
go.mod +3 −3
@@ -6,9 +6,12 @@ require (
66 github.com/BurntSushi/toml v1.6.0
77 github.com/ProtonMail/go-crypto v1.4.1
88 github.com/alecthomas/chroma/v2 v2.27.0
9 github.com/microcosm-cc/bluemonday v1.0.27
10 github.com/niklasfasching/go-org v1.9.1
911 github.com/spf13/cobra v1.10.2
1012 github.com/yuin/goldmark v1.8.5
1113 golang.org/x/crypto v0.55.0
14 golang.org/x/net v0.57.0
1215 golang.org/x/term v0.45.0
1316 modernc.org/sqlite v1.57.0
1417 )
@@ -23,14 +26,11 @@ require (
2326 github.com/gorilla/css v1.0.1 // indirect
2427 github.com/inconshreveable/mousetrap v1.1.0 // indirect
2528 github.com/mattn/go-isatty v0.0.24 // indirect
26 github.com/microcosm-cc/bluemonday v1.0.27 // indirect
2729 github.com/ncruces/go-strftime v1.0.0 // indirect
28 github.com/niklasfasching/go-org v1.9.1 // indirect
2930 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
3031 github.com/russross/blackfriday/v2 v2.1.0 // indirect
3132 github.com/spf13/pflag v1.0.9 // indirect
3233 go.yaml.in/yaml/v3 v3.0.4 // indirect
33 golang.org/x/net v0.57.0 // indirect
3434 golang.org/x/sys v0.47.0 // indirect
3535 golang.org/x/text v0.41.0 // indirect
3636 modernc.org/libc v1.74.4 // indirect
go.sum +2
@@ -38,6 +38,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
3838github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
3939github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
4040github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48=
41github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
42github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
4143github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
4244github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
4345github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
internal/autolink/autolink.go added +178
@@ -0,0 +1,178 @@
1// Package autolink rewrites cross-references in rendered HTML: #N and !N
2// to the repository's issues and merge requests, owner/name#N (and !N)
3// across repositories, and @user to owner pages. It operates on the HTML
4// produced by the markdown/org pipeline, walking text nodes with a real
5// parser so nothing inside <a>, <code>, or <pre> is ever touched, and only
6// references that actually resolve become links.
7package autolink
8
9import (
10 "fmt"
11 "regexp"
12 "strconv"
13 "strings"
14
15 "golang.org/x/net/html"
16 "golang.org/x/net/html/atom"
17)
18
19// Resolver answers whether a reference target exists and where it lives.
20// Empty return means "not a real target: leave the text alone".
21type Resolver interface {
22 // RefURL resolves issue (#) or merge request (!) number n in
23 // owner/name; kind is '#' or '!'.
24 RefURL(owner, name string, kind byte, n int64) string
25 // UserURL resolves a user or org name to its owner page.
26 UserURL(name string) string
27}
28
29var (
30 // owner/name#N or owner/name!N
31 crossRefPat = regexp.MustCompile(`([a-z0-9][a-z0-9._-]*)/([a-z0-9][a-z0-9._-]*)([#!])([0-9]+)`)
32 // #N or !N with a boundary before, so a1b2#3 in a hash stays text
33 bareRefPat = regexp.MustCompile(`(^|[\s([{])([#!])([0-9]+)\b`)
34 // @user with a boundary before
35 mentionPat = regexp.MustCompile(`(^|[\s([{])@([a-z0-9][a-z0-9._-]*)`)
36)
37
38// skip lists elements whose text must never be rewritten.
39var skip = map[string]bool{"a": true, "code": true, "pre": true, "script": true, "style": true}
40
41// Rewrite processes an HTML fragment, linking references relative to
42// defaultOwner/defaultName. On any parse failure the input is returned
43// unchanged.
44func Rewrite(fragment, defaultOwner, defaultName string, r Resolver) string {
45 ctx := &html.Node{Type: html.ElementNode, Data: "div", DataAtom: atom.Div}
46 nodes, err := html.ParseFragment(strings.NewReader(fragment), ctx)
47 if err != nil {
48 return fragment
49 }
50 var out strings.Builder
51 for _, n := range nodes {
52 walk(n, defaultOwner, defaultName, r)
53 if err := html.Render(&out, n); err != nil {
54 return fragment
55 }
56 }
57 return out.String()
58}
59
60func walk(n *html.Node, owner, name string, r Resolver) {
61 if n.Type == html.ElementNode && skip[n.Data] {
62 return
63 }
64 for c := n.FirstChild; c != nil; {
65 next := c.NextSibling
66 if c.Type == html.TextNode {
67 if repl := rewriteText(c.Data, owner, name, r); repl != nil {
68 for _, rn := range repl {
69 n.InsertBefore(rn, c)
70 }
71 n.RemoveChild(c)
72 }
73 } else {
74 walk(c, owner, name, r)
75 }
76 c = next
77 }
78}
79
80type span struct {
81 start, end int
82 url, text string
83}
84
85// rewriteText returns replacement nodes for a text node, or nil when no
86// reference resolved.
87func rewriteText(text, owner, name string, r Resolver) []*html.Node {
88 var spans []span
89
90 for _, m := range crossRefPat.FindAllStringSubmatchIndex(text, -1) {
91 o, rep := text[m[2]:m[3]], text[m[4]:m[5]]
92 kind := text[m[6]]
93 n, _ := strconv.ParseInt(text[m[8]:m[9]], 10, 64)
94 if url := r.RefURL(o, rep, kind, n); url != "" {
95 spans = append(spans, span{m[0], m[1], url, text[m[0]:m[1]]})
96 }
97 }
98 for _, m := range bareRefPat.FindAllStringSubmatchIndex(text, -1) {
99 kind := text[m[4]]
100 n, _ := strconv.ParseInt(text[m[6]:m[7]], 10, 64)
101 if overlaps(spans, m[4], m[7]) {
102 continue
103 }
104 if url := r.RefURL(owner, name, kind, n); url != "" {
105 spans = append(spans, span{m[4], m[7], url, text[m[4]:m[7]]})
106 }
107 }
108 for _, m := range mentionPat.FindAllStringSubmatchIndex(text, -1) {
109 if overlaps(spans, m[4]-1, m[5]) {
110 continue
111 }
112 who := text[m[4]:m[5]]
113 url := r.UserURL(who)
114 if url == "" {
115 // Names may legally contain ._- but a sentence-ending
116 // "@alice." usually means the user, not "alice.".
117 trimmed := strings.TrimRight(who, "._-")
118 if trimmed != "" && trimmed != who {
119 if u := r.UserURL(trimmed); u != "" {
120 who, url = trimmed, u
121 }
122 }
123 }
124 if url != "" {
125 spans = append(spans, span{m[4] - 1, m[4] + len(who), url, "@" + who})
126 }
127 }
128 if len(spans) == 0 {
129 return nil
130 }
131 sortSpans(spans)
132
133 var nodes []*html.Node
134 pos := 0
135 for _, s := range spans {
136 if s.start < pos {
137 continue // overlap safety
138 }
139 if s.start > pos {
140 nodes = append(nodes, &html.Node{Type: html.TextNode, Data: text[pos:s.start]})
141 }
142 a := &html.Node{Type: html.ElementNode, Data: "a",
143 Attr: []html.Attribute{{Key: "href", Val: s.url}, {Key: "class", Val: "xref"}}}
144 a.AppendChild(&html.Node{Type: html.TextNode, Data: s.text})
145 nodes = append(nodes, a)
146 pos = s.end
147 }
148 if pos < len(text) {
149 nodes = append(nodes, &html.Node{Type: html.TextNode, Data: text[pos:]})
150 }
151 return nodes
152}
153
154func overlaps(spans []span, start, end int) bool {
155 for _, s := range spans {
156 if start < s.end && end > s.start {
157 return true
158 }
159 }
160 return false
161}
162
163func sortSpans(spans []span) {
164 for i := 1; i < len(spans); i++ {
165 for j := i; j > 0 && spans[j].start < spans[j-1].start; j-- {
166 spans[j], spans[j-1] = spans[j-1], spans[j]
167 }
168 }
169}
170
171// Format helpers shared with the resolver implementation.
172func IssueURL(owner, name string, n int64) string {
173 return fmt.Sprintf("/%s/%s/issues/%d", owner, name, n)
174}
175
176func MRURL(owner, name string, n int64) string {
177 return fmt.Sprintf("/%s/%s/mrs/%d", owner, name, n)
178}
internal/autolink/autolink_test.go added +102
@@ -0,0 +1,102 @@
1package autolink
2
3import (
4 "strings"
5 "testing"
6)
7
8// fakeResolver knows issue 4 and MR 2 in krz/gitbay, issue 7 in cmc/tools,
9// and users alice and krz.
10type fakeResolver struct{}
11
12func (fakeResolver) RefURL(owner, name string, kind byte, n int64) string {
13 switch {
14 case owner == "krz" && name == "gitbay" && kind == '#' && n == 4:
15 return IssueURL(owner, name, n)
16 case owner == "krz" && name == "gitbay" && kind == '!' && n == 2:
17 return MRURL(owner, name, n)
18 case owner == "cmc" && name == "tools" && kind == '#' && n == 7:
19 return IssueURL(owner, name, n)
20 }
21 return ""
22}
23
24func (fakeResolver) UserURL(name string) string {
25 if name == "alice" || name == "krz" {
26 return "/" + name
27 }
28 return ""
29}
30
31func rw(t *testing.T, in string) string {
32 t.Helper()
33 return Rewrite(in, "krz", "gitbay", fakeResolver{})
34}
35
36func TestRewrite(t *testing.T) {
37 cases := []struct {
38 name, in string
39 want []string // substrings that must appear
40 wantNot []string
41 }{
42 {"bare issue ref", "<p>see #4 for details</p>",
43 []string{`<a href="/krz/gitbay/issues/4" class="xref">#4</a>`}, nil},
44 {"bare mr ref", "<p>fixed in !2.</p>",
45 []string{`<a href="/krz/gitbay/mrs/2" class="xref">!2</a>`}, nil},
46 {"cross-repo ref", "<p>tracked at cmc/tools#7 upstream</p>",
47 []string{`<a href="/cmc/tools/issues/7" class="xref">cmc/tools#7</a>`}, nil},
48 {"mention", "<p>ping @alice about it</p>",
49 []string{`<a href="/alice" class="xref">@alice</a>`}, nil},
50 {"org mention", "<p>@krz owns this</p>",
51 []string{`<a href="/krz" class="xref">@krz</a>`}, nil},
52 {"nonexistent issue stays text", "<p>see #999</p>",
53 []string{"<p>see #999</p>"}, []string{"<a"}},
54 {"nonexistent user stays text", "<p>hi @nobody</p>",
55 []string{"<p>hi @nobody</p>"}, []string{"<a"}},
56 {"code spans untouched", "<p>run <code>git show #4</code> now</p>",
57 []string{"<code>git show #4</code>"}, []string{`issues/4`}},
58 {"pre blocks untouched", "<pre>#4 !2 @alice</pre>",
59 []string{"<pre>#4 !2 @alice</pre>"}, []string{"<a"}},
60 {"existing links untouched", `<a href="/x">#4</a>`,
61 []string{`<a href="/x">#4</a>`}, []string{"issues/4"}},
62 {"mid-word hash not a ref", "<p>sha a1b2#4 is odd</p>",
63 nil, []string{"<a"}},
64 {"mid-word at not a mention", "<p>mail me@alice.example ok</p>",
65 nil, []string{"<a"}},
66 {"multiple refs one line", "<p>#4 and !2 and @alice</p>",
67 []string{"issues/4", "mrs/2", `href="/alice"`}, nil},
68 {"nested markup", "<ul><li>fixes #4</li><li><em>see !2</em></li></ul>",
69 []string{"issues/4", "mrs/2"}, nil},
70 {"punctuation after ref", "<p>(#4), and #4.</p>",
71 []string{`class="xref">#4</a>),`}, nil},
72 {"mention with trailing period", "<p>ask @alice.</p>",
73 []string{`class="xref">@alice</a>.`}, nil},
74 }
75 for _, tc := range cases {
76 t.Run(tc.name, func(t *testing.T) {
77 got := rw(t, tc.in)
78 for _, w := range tc.want {
79 if !strings.Contains(got, w) {
80 t.Errorf("missing %q in:\n%s", w, got)
81 }
82 }
83 for _, w := range tc.wantNot {
84 if strings.Contains(got, w) {
85 t.Errorf("unexpected %q in:\n%s", w, got)
86 }
87 }
88 })
89 }
90}
91
92func TestRewriteEscaping(t *testing.T) {
93 // Text around references must stay properly escaped after the
94 // parse/render round trip.
95 got := rw(t, "<p>x &lt;script&gt; #4 &amp; done</p>")
96 if !strings.Contains(got, "&lt;script&gt;") || !strings.Contains(got, "&amp; done") {
97 t.Fatalf("escaping lost:\n%s", got)
98 }
99 if !strings.Contains(got, "issues/4") {
100 t.Fatalf("ref not linked:\n%s", got)
101 }
102}