fix(markup): make heading anchors match GitHub for whitespace and combining marks (#40195)

Heading anchors now keep the same characters as GitHub: letters, marks,
decimal and letter numbers, connector punctuation and alphabetic
symbols, with ASCII spaces turned into `-`. The rule follows
[github-slugger](https://github.com/Flet/github-slugger), whose fixtures
are generated from real GitHub renders.

- Other whitespace, including newlines in multi-line headings, is
dropped instead of becoming `-`, so `1<em space>ab` gets `#1ab`
- Combining marks are kept, so `हिन्दी` no longer becomes `हनद`
- Other numbers like `²` and `①` are dropped

Fixes https://github.com/go-gitea/gitea/issues/27754

This PR was written with Claude Code.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
authored and GitHub committed 2026-11-04 05:51:12 +00:00
1 parent d919301d36
commit ab295e62e4
2 files changed
+12 -12

No files matched your search

+8 -11
View File
@@ -26,18 +26,15 @@ import (
// and more closely matches the output from the shurcooL sanitizer
// In particular Unicode letters and numbers are a lot more than a-zA-Z0-9...
func CleanValue(value []byte) []byte {
value = bytes.TrimSpace(value)
rs := bytes.Runes(value)
result := make([]rune, 0, len(rs))
for _, r := range rs {
if unicode.IsLetter(r) || unicode.IsNumber(r) || r == '_' || r == '-' {
result = append(result, unicode.ToLower(r))
return bytes.Map(func(r rune) rune {
switch {
case r == ' ':
return '-'
case r == '-' || unicode.In(r, unicode.L, unicode.M, unicode.Nd, unicode.Nl, unicode.Pc, unicode.Other_Alphabetic):
return unicode.ToLower(r)
}
if unicode.IsSpace(r) {
result = append(result, '-')
}
}
return []byte(string(result))
return -1
}, bytes.TrimSpace(value))
}
// Most of what follows is a subtly changed version of github.com/yuin/goldmark/extension/footnote.go
+4 -1
View File
@@ -50,9 +50,12 @@ func TestCleanValue(t *testing.T) {
{"tes\\a", "tesa"},
{"tes/a", "tesa"},
{"a啊啊b", "a啊啊b"},
{"c🤔️🤔️d", "cd"},
{"c🤔️🤔️d", "c\ufe0f\ufe0fd"},
{"a⚡a", "aa"},
{"e.~f", "ef"},
{"a \u2003 b", "a--b"},
{"हिन्दी", "हिन्दी"},
{"m²", "m"},
}
for _, test := range tests {
assert.Equal(t, []byte(test.expect), CleanValue([]byte(test.param)), test.param)