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 | package httpd | 1 | package httpd |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "html/template" | ||
| 4 | "os" | 5 | "os" |
| 5 | "path/filepath" | 6 | "path/filepath" |
| 6 | "strings" | 7 | "strings" |
| @@ -165,3 +166,28 @@ func TestUGCHTMLOrgCannotReadServerFiles(t *testing.T) { | |||
| 165 | t.Fatalf("an org body read a server file:\n%s", out) | 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 | return fenceHighlight(source, lang) | 1222 | return fenceHighlight(source, lang) |
| 1223 | } | 1223 | } |
| 1224 | writer.ExtendingWriter = &orgWriter{writer} | ||
| 1224 | out, err := doc.Write(writer) | 1225 | out, err := doc.Write(writer) |
| 1225 | if err != nil { | 1226 | if err != nil { |
| 1226 | return fallback() | 1227 | return fallback() |
| @@ -1228,6 +1229,48 @@ func renderOrg(name string, raw []byte, contents bool, fallback func() template. | |||
| 1228 | return template.HTML(ugcPolicy.Sanitize(out)) | 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 | // headingTag matches an opening or closing h1..h5 tag, so a rendered | 1274 | // headingTag matches an opening or closing h1..h5 tag, so a rendered |
| 1232 | // document's headings can move down one level. | 1275 | // document's headings can move down one level. |
| 1233 | var headingTag = regexp.MustCompile(`<(/?)h([1-5])([\s>])`) | 1276 | var headingTag = regexp.MustCompile(`<(/?)h([1-5])([\s>])`) |