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 @@
11package httpd
22
33import (
4 "html/template"
45 "os"
56 "path/filepath"
67 "strings"
@@ -165,3 +166,28 @@ func TestUGCHTMLOrgCannotReadServerFiles(t *testing.T) {
165166 t.Fatalf("an org body read a server file:\n%s", out)
166167 }
167168}
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.
175func 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.
12211221 }
12221222 return fenceHighlight(source, lang)
12231223 }
1224 writer.ExtendingWriter = &orgWriter{writer}
12241225 out, err := doc.Write(writer)
12251226 if err != nil {
12261227 return fallback()
@@ -1228,6 +1229,48 @@ func renderOrg(name string, raw []byte, contents bool, fallback func() template.
12281229 return template.HTML(ugcPolicy.Sanitize(out))
12291230}
12301231
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.
1237type orgWriter struct {
1238 *org.HTMLWriter
1239}
1240
1241func (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.
1256func 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
12311274// headingTag matches an opening or closing h1..h5 tag, so a rendered
12321275// document's headings can move down one level.
12331276var headingTag = regexp.MustCompile(`<(/?)h([1-5])([\s>])`)