internal/autolink/autolink.go
178 lines · 5210 bytes
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}