org autolinks stop before trailing punctuation !371
2 files changed, +69 −0
Layout: unified · split
internal/httpd/orgrender_test.go +26
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | package httpd |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "html/template" | |
| 4 | 5 | "os" |
| 5 | 6 | "path/filepath" |
| 6 | 7 | "strings" |
| @@ -165,3 +166,28 @@ func TestUGCHTMLOrgCannotReadServerFiles(t *testing.T) { | ||
| 165 | 166 | t.Fatalf("an org body read a server file:\n%s", out) |
| 166 | 167 | } |
| 167 | 168 | } |
| 169 | ||
| 170 | // go-org's autolink parser takes every RFC 3986 character, trailing | |
| 171 | // punctuation included, so a bare URL at the end of a sentence or inside | |
| 172 | // parentheses swallowed the `).` after it. Org itself stops a plain link | |
| 173 | // before trailing punctuation and only keeps a `)` that closes a `(` inside | |
| 174 | // the link. | |
| 175 | func TestOrgAutolinkStopsBeforeTrailingPunctuation(t *testing.T) { | |
| 176 | cases := []struct{ src, href, after string }{ | |
| 177 | {"fork of X (https://git.example/a/B). upstream", "https://git.example/a/B", "). upstream"}, | |
| 178 | {"see https://example.com.", "https://example.com", "."}, | |
| 179 | {"see https://example.com/q?x=1,", "https://example.com/q?x=1", ","}, | |
| 180 | {"see https://en.wikipedia.org/wiki/Foo_(bar) now", "https://en.wikipedia.org/wiki/Foo_(bar)", " now"}, | |
| 181 | {"(see https://en.wikipedia.org/wiki/Foo_(bar)).", "https://en.wikipedia.org/wiki/Foo_(bar)", ")."}, | |
| 182 | } | |
| 183 | for _, c := range cases { | |
| 184 | out := string(renderReadme("README.org", []byte(c.src+"\n"))) | |
| 185 | want := `href="` + c.href + `"` | |
| 186 | if !strings.Contains(out, want) { | |
| 187 | t.Errorf("%q: want %s in\n%s", c.src, want, out) | |
| 188 | } | |
| 189 | if !strings.Contains(out, "</a>"+template.HTMLEscapeString(c.after)) { | |
| 190 | t.Errorf("%q: want %q after the link in\n%s", c.src, c.after, out) | |
| 191 | } | |
| 192 | } | |
| 193 | } | |
internal/httpd/web.go +43
| @@ -1221,6 +1221,7 @@ func renderOrg(name string, raw []byte, contents bool, fallback func() template. | ||
| 1221 | 1221 | } |
| 1222 | 1222 | return fenceHighlight(source, lang) |
| 1223 | 1223 | } |
| 1224 | writer.ExtendingWriter = &orgWriter{writer} | |
| 1224 | 1225 | out, err := doc.Write(writer) |
| 1225 | 1226 | if err != nil { |
| 1226 | 1227 | return fallback() |
| @@ -1228,6 +1229,48 @@ func renderOrg(name string, raw []byte, contents bool, fallback func() template. | ||
| 1228 | 1229 | return template.HTML(ugcPolicy.Sanitize(out)) |
| 1229 | 1230 | } |
| 1230 | 1231 | |
| 1232 | // orgWriter overrides go-org's autolink rendering. go-org ends a bare URL | |
| 1233 | // at the first character outside RFC 3986's set, and that set includes | |
| 1234 | // `.`, `,` and `)`, so a URL closing a sentence or a parenthesis took the | |
| 1235 | // punctuation with it. Org stops a plain link before trailing punctuation | |
| 1236 | // and keeps a `)` only when a `(` inside the link opened it. | |
| 1237 | type orgWriter struct { | |
| 1238 | *org.HTMLWriter | |
| 1239 | } | |
| 1240 | ||
| 1241 | func (w *orgWriter) WriteRegularLink(l org.RegularLink) { | |
| 1242 | if !l.AutoLink { | |
| 1243 | w.HTMLWriter.WriteRegularLink(l) | |
| 1244 | return | |
| 1245 | } | |
| 1246 | url, rest := splitAutolinkPunctuation(l.URL) | |
| 1247 | l.URL = url | |
| 1248 | w.HTMLWriter.WriteRegularLink(l) | |
| 1249 | if rest != "" { | |
| 1250 | w.WriteText(org.Text{Content: rest}) | |
| 1251 | } | |
| 1252 | } | |
| 1253 | ||
| 1254 | // splitAutolinkPunctuation returns the URL without trailing sentence | |
| 1255 | // punctuation, and the punctuation it removed. | |
| 1256 | func splitAutolinkPunctuation(url string) (string, string) { | |
| 1257 | end := len(url) | |
| 1258 | for end > 0 { | |
| 1259 | switch url[end-1] { | |
| 1260 | case '.', ',', ';', ':', '!', '?', '\'', '"': | |
| 1261 | end-- | |
| 1262 | continue | |
| 1263 | case ')': | |
| 1264 | if strings.Count(url[:end], ")") > strings.Count(url[:end], "(") { | |
| 1265 | end-- | |
| 1266 | continue | |
| 1267 | } | |
| 1268 | } | |
| 1269 | break | |
| 1270 | } | |
| 1271 | return url[:end], url[end:] | |
| 1272 | } | |
| 1273 | ||
| 1231 | 1274 | // headingTag matches an opening or closing h1..h5 tag, so a rendered |
| 1232 | 1275 | // document's headings can move down one level. |
| 1233 | 1276 | var headingTag = regexp.MustCompile(`<(/?)h([1-5])([\s>])`) |