Commit b34003973f
Verified · cmc
go.mod +3 −3
| @@ -6,9 +6,12 @@ require ( | ||
| 6 | 6 | github.com/BurntSushi/toml v1.6.0 |
| 7 | 7 | github.com/ProtonMail/go-crypto v1.4.1 |
| 8 | 8 | 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 | |
| 9 | 11 | github.com/spf13/cobra v1.10.2 |
| 10 | 12 | github.com/yuin/goldmark v1.8.5 |
| 11 | 13 | golang.org/x/crypto v0.55.0 |
| 14 | golang.org/x/net v0.57.0 | |
| 12 | 15 | golang.org/x/term v0.45.0 |
| 13 | 16 | modernc.org/sqlite v1.57.0 |
| 14 | 17 | ) |
| @@ -23,14 +26,11 @@ require ( | ||
| 23 | 26 | github.com/gorilla/css v1.0.1 // indirect |
| 24 | 27 | github.com/inconshreveable/mousetrap v1.1.0 // indirect |
| 25 | 28 | github.com/mattn/go-isatty v0.0.24 // indirect |
| 26 | github.com/microcosm-cc/bluemonday v1.0.27 // indirect | |
| 27 | 29 | github.com/ncruces/go-strftime v1.0.0 // indirect |
| 28 | github.com/niklasfasching/go-org v1.9.1 // indirect | |
| 29 | 30 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| 30 | 31 | github.com/russross/blackfriday/v2 v2.1.0 // indirect |
| 31 | 32 | github.com/spf13/pflag v1.0.9 // indirect |
| 32 | 33 | go.yaml.in/yaml/v3 v3.0.4 // indirect |
| 33 | golang.org/x/net v0.57.0 // indirect | |
| 34 | 34 | golang.org/x/sys v0.47.0 // indirect |
| 35 | 35 | golang.org/x/text v0.41.0 // indirect |
| 36 | 36 | 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 | ||
| 38 | 38 | github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| 39 | 39 | github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0= |
| 40 | 40 | github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48= |
| 41 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | |
| 42 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | |
| 41 | 43 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| 42 | 44 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| 43 | 45 | github.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. | |
| 7 | package autolink | |
| 8 | ||
| 9 | import ( | |
| 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". | |
| 21 | type 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 | ||
| 29 | var ( | |
| 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. | |
| 39 | var 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. | |
| 44 | func 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 | ||
| 60 | func 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 | ||
| 80 | type 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 | func 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 | ||
| 154 | func 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 | ||
| 163 | func 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. | |
| 172 | func IssueURL(owner, name string, n int64) string { | |
| 173 | return fmt.Sprintf("/%s/%s/issues/%d", owner, name, n) | |
| 174 | } | |
| 175 | ||
| 176 | func 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 @@ | ||
| 1 | package autolink | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 10 | type fakeResolver struct{} | |
| 11 | ||
| 12 | func (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 | ||
| 24 | func (fakeResolver) UserURL(name string) string { | |
| 25 | if name == "alice" || name == "krz" { | |
| 26 | return "/" + name | |
| 27 | } | |
| 28 | return "" | |
| 29 | } | |
| 30 | ||
| 31 | func rw(t *testing.T, in string) string { | |
| 32 | t.Helper() | |
| 33 | return Rewrite(in, "krz", "gitbay", fakeResolver{}) | |
| 34 | } | |
| 35 | ||
| 36 | func 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 | ||
| 92 | func 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 <script> #4 & done</p>") | |
| 96 | if !strings.Contains(got, "<script>") || !strings.Contains(got, "& 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 | } | |