internal/autolink/autolink.go

419f6dfdc5489a0c6374e36dd1ebbfca68040056
gitbay/internal/autolink/autolink.go history · blame · raw

194 lines · 5698 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.
 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}