internal/autolink/autolink.go

7cc95d1fc1115f22008751a05e9e07156d49d6b0
gitbay/internal/autolink/autolink.go history · blame · raw

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}