Commit 97d8bd46e4
97d8bd46e43b1947534ce1aa07558f351b7e509d
parent: ad34785bfc
Verified · cmc ci/build: success ci/test: success
cmc <hello@cleberg.net> · 2026-09-11 01:27 UTC
httpd: org autolinks stop before trailing punctuation
go-org ends a bare URL at the first character outside RFC 3986's set,
which includes `.`, `,` and `)`, so a URL closing a sentence or a
parenthesis took the punctuation into the href. An extending writer
splits it back out as text; a `)` stays when a `(` in the link opened
it.
Closes #209
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>])`) |