diff --git a/internal/services/template.go b/internal/services/template.go index 8f114f0..f7bf2ec 100644 --- a/internal/services/template.go +++ b/internal/services/template.go @@ -1033,6 +1033,16 @@ func extractLinkDisplay(urlStr string) string { strings.TrimSuffix(u.EscapedPath(), "/"), func(r rune) bool { return r == '/' }, ) + // Percent-escapes belong on the wire, not in display text — a reader should see + // "über", not "%C3%BCber". Decode after splitting, so an encoded %2F cannot + // invent a segment boundary, and before measuring, so the cap counts the + // characters that actually reach the pill. + for i, seg := range segments { + if decoded, decErr := url.PathUnescape(seg); decErr == nil { + segments[i] = decoded + } + } + for len(segments) > 0 { candidate := host + "/" + strings.Join(segments, "/") if utf8.RuneCountInString(candidate) <= linkDisplayCap { diff --git a/internal/services/template_test.go b/internal/services/template_test.go index a2902c9..5167424 100644 --- a/internal/services/template_test.go +++ b/internal/services/template_test.go @@ -1227,6 +1227,17 @@ func TestExtractLinkDisplay(t *testing.T) { {"fragment dropped", "https://go.dev/ref/mod#go-mod-file-retract", "go.dev/ref/mod"}, + // Percent-escapes are decoded for display; the reader sees the character, + // not its wire encoding. + {"unicode path decoded", "https://exämple.de/über", "exämple.de/über"}, + {"escaped space decoded", "https://example.com/a%20b/c", "example.com/a b/c"}, + // Decoding happens after the split, so an encoded slash cannot invent a + // segment boundary. + {"encoded slash stays in its segment", "https://example.com/a%2Fb", "example.com/a/b"}, + + {"port dropped", "https://example.com:8443/path", "example.com/path"}, + {"empty path segments collapse", "https://example.com//double//slash", "example.com/double/slash"}, + // Query deliberately not shown — it cannot fit the mobile pill, and the // card title carries primary identity. {"query dropped", "https://news.ycombinator.com/item?id=12345", "news.ycombinator.com/item"}, @@ -1250,9 +1261,9 @@ func TestExtractLinkDisplay(t *testing.T) { } } -// Whatever the helper emits must fit the pill's character budget on its own. The -// CSS ellipsis is the safety net for pathological input; it should not be what -// keeps ordinary links readable. +// Anything the helper can shorten by dropping segments must fit the pill's budget +// on its own. The CSS ellipsis is the safety net for input the helper cannot +// shorten; it should not be what keeps ordinary links readable. func TestExtractLinkDisplay_StaysWithinPillBudget(t *testing.T) { longs := []string{ "https://example.com/a/very/deep/path/that/keeps/going/and/going/forever", @@ -1268,3 +1279,14 @@ func TestExtractLinkDisplay_StaysWithinPillBudget(t *testing.T) { "segments are dropped whole, so no ellipsis should reach the template") } } + +// A hostname longer than the budget is returned whole and left to the CSS +// ellipsis. Truncating it here would emit something that is not a hostname, which +// reads worse than a visibly clipped one — so this documents the one input class +// where CSS is genuinely load-bearing. +func TestExtractLinkDisplay_LongHostExceedsBudgetByDesign(t *testing.T) { + got := extractLinkDisplay("https://a.very.long.subdomain.chain.example.co.uk/x") + assert.Equal(t, "a.very.long.subdomain.chain.example.co.uk", got) + assert.Greater(t, utf8.RuneCountInString(got), linkDisplayCap, + "if this now fits, the cap changed and the CSS-fallback rationale needs revisiting") +}