internal/autolink/autolink.go
194 lines · 5719 bytes
14 symbols in this file
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>, <pre> or <math> 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, "math": 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.
87// Mentions returns the distinct @names in text, in order of appearance,
88// as written. A name may carry trailing punctuation the writer meant as
89// prose ("@alice."); the caller resolves and, failing that, trims ._-
90// the way Rewrite does.
91func Mentions(text string) []string {
92 seen := map[string]bool{}
93 var out []string
94 for _, m := range mentionPat.FindAllStringSubmatch(text, -1) {
95 if who := m[2]; !seen[who] {
96 seen[who] = true
97 out = append(out, who)
98 }
99 }
100 return out
101}
102
103func rewriteText(text, owner, name string, r Resolver) []*html.Node {
104 var spans []span
105
106 for _, m := range crossRefPat.FindAllStringSubmatchIndex(text, -1) {
107 o, rep := text[m[2]:m[3]], text[m[4]:m[5]]
108 kind := text[m[6]]
109 n, _ := strconv.ParseInt(text[m[8]:m[9]], 10, 64)
110 if url := r.RefURL(o, rep, kind, n); url != "" {
111 spans = append(spans, span{m[0], m[1], url, text[m[0]:m[1]]})
112 }
113 }
114 for _, m := range bareRefPat.FindAllStringSubmatchIndex(text, -1) {
115 kind := text[m[4]]
116 n, _ := strconv.ParseInt(text[m[6]:m[7]], 10, 64)
117 if overlaps(spans, m[4], m[7]) {
118 continue
119 }
120 if url := r.RefURL(owner, name, kind, n); url != "" {
121 spans = append(spans, span{m[4], m[7], url, text[m[4]:m[7]]})
122 }
123 }
124 for _, m := range mentionPat.FindAllStringSubmatchIndex(text, -1) {
125 if overlaps(spans, m[4]-1, m[5]) {
126 continue
127 }
128 who := text[m[4]:m[5]]
129 url := r.UserURL(who)
130 if url == "" {
131 // Names may legally contain ._- but a sentence-ending
132 // "@alice." usually means the user, not "alice.".
133 trimmed := strings.TrimRight(who, "._-")
134 if trimmed != "" && trimmed != who {
135 if u := r.UserURL(trimmed); u != "" {
136 who, url = trimmed, u
137 }
138 }
139 }
140 if url != "" {
141 spans = append(spans, span{m[4] - 1, m[4] + len(who), url, "@" + who})
142 }
143 }
144 if len(spans) == 0 {
145 return nil
146 }
147 sortSpans(spans)
148
149 var nodes []*html.Node
150 pos := 0
151 for _, s := range spans {
152 if s.start < pos {
153 continue // overlap safety
154 }
155 if s.start > pos {
156 nodes = append(nodes, &html.Node{Type: html.TextNode, Data: text[pos:s.start]})
157 }
158 a := &html.Node{Type: html.ElementNode, Data: "a",
159 Attr: []html.Attribute{{Key: "href", Val: s.url}, {Key: "class", Val: "xref"}}}
160 a.AppendChild(&html.Node{Type: html.TextNode, Data: s.text})
161 nodes = append(nodes, a)
162 pos = s.end
163 }
164 if pos < len(text) {
165 nodes = append(nodes, &html.Node{Type: html.TextNode, Data: text[pos:]})
166 }
167 return nodes
168}
169
170func overlaps(spans []span, start, end int) bool {
171 for _, s := range spans {
172 if start < s.end && end > s.start {
173 return true
174 }
175 }
176 return false
177}
178
179func sortSpans(spans []span) {
180 for i := 1; i < len(spans); i++ {
181 for j := i; j > 0 && spans[j].start < spans[j-1].start; j-- {
182 spans[j], spans[j-1] = spans[j-1], spans[j]
183 }
184 }
185}
186
187// Format helpers shared with the resolver implementation.
188func IssueURL(owner, name string, n int64) string {
189 return fmt.Sprintf("/%s/%s/issues/%d", owner, name, n)
190}
191
192func MRURL(owner, name string, n int64) string {
193 return fmt.Sprintf("/%s/%s/mrs/%d", owner, name, n)
194}