From 23fa5427b7a338ae18aecc774d19548e66beb48d Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 3 Jun 2026 15:10:50 +0200 Subject: [PATCH] feat(web): previewText truncation helper for summary cards One-line lede for list cards: collapses whitespace, prefers the first sentence within budget, else word-boundary truncation with an ellipsis. Pure and rune-based (multibyte-safe). Table-driven tests cover empty, short, first-sentence, word-boundary, and multibyte cases. --- internal/web/view.go | 45 +++++++++++++++++ internal/web/view_test.go | 104 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 internal/web/view_test.go diff --git a/internal/web/view.go b/internal/web/view.go index 5f3a450..0158e5b 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -100,6 +100,51 @@ func detailMeta(r store.SummaryRow) string { return strings.Join(parts, " · ") } +// previewText renders a one-line lede for a summary card: it collapses internal +// whitespace, then returns the first sentence when one ends within max runes, +// otherwise truncates at max runes on a word boundary (never mid-word) and +// appends an ellipsis. Empty/short input is returned unchanged (no ellipsis). +// Pure and multibyte-safe — all length work is on runes, not bytes. +func previewText(s string, max int) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + s = strings.Join(strings.Fields(s), " ") + runes := []rune(s) + + // Prefer the first sentence when it terminates within the budget. + if end := firstSentenceEnd(runes); end > 0 && end <= max { + return string(runes[:end]) + } + if len(runes) <= max { + return s + } + + // Truncate at max runes, then back off to the last word boundary so no + // partial word is emitted. Space is single-byte, so the byte-index slice + // lands cleanly on a rune boundary. + cut := string(runes[:max]) + if i := strings.LastIndexByte(cut, ' '); i > 0 { + cut = cut[:i] + } + return strings.TrimRight(cut, " ") + "…" +} + +// firstSentenceEnd returns the rune index just past the first sentence +// terminator (. ! ?) that is followed by whitespace or the end of input, or 0 +// when there is none. +func firstSentenceEnd(runes []rune) int { + for i, r := range runes { + if r == '.' || r == '!' || r == '?' { + if i+1 == len(runes) || runes[i+1] == ' ' { + return i + 1 + } + } + } + return 0 +} + // videoURL builds the internal detail-page path for a video id. func videoURL(videoID string) templ.SafeURL { return templ.SafeURL("/v/" + videoID) diff --git a/internal/web/view_test.go b/internal/web/view_test.go new file mode 100644 index 0000000..81e04da --- /dev/null +++ b/internal/web/view_test.go @@ -0,0 +1,104 @@ +package web + +import "testing" + +func TestPreviewText(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + max int + want string + }{ + { + name: "empty input", + in: "", + max: 160, + want: "", + }, + { + name: "whitespace-only input", + in: " \n\t ", + max: 160, + want: "", + }, + { + name: "short string unchanged", + in: "A tidy little summary", + max: 160, + want: "A tidy little summary", + }, + { + name: "short single sentence unchanged", + in: "Hello there.", + max: 160, + want: "Hello there.", + }, + { + name: "first sentence taken when more follows", + in: "First sentence. Second sentence that we drop.", + max: 160, + want: "First sentence.", + }, + { + name: "first sentence with question mark", + in: "What is this? It is a preview.", + max: 160, + want: "What is this?", + }, + { + name: "long string truncated on word boundary with ellipsis", + // 5 ten-char words past the limit; max cuts mid "ones". + in: "alpha bravo charlie delta echo foxtrot golf hotel india juliet", + max: 30, + // runes[:30] = "alpha bravo charlie delta echo"; ends exactly on a + // word so the next char would be a space — backs off to last space. + want: "alpha bravo charlie delta…", + }, + { + name: "no mid-word cut", + in: "internationalization frameworks everywhere today", + max: 25, + // runes[:25] = "internationalization fram" — back off to the space + // after the first word; never emit a partial word. + want: "internationalization…", + }, + { + name: "multibyte safe truncation", + // Accented + emoji runes; cutting on rune indices must not split a + // multibyte sequence. + in: "café déjà vû señor naïve résumé piñata fiancé", + max: 20, + want: "café déjà vû señor…", + }, + { + name: "multibyte short unchanged", + in: "café señor", + max: 160, + want: "café señor", + }, + { + name: "collapses internal whitespace", + in: "line one\n\n line two\tline three", + max: 160, + want: "line one line two line three", + }, + { + name: "sentence beyond max falls back to char truncation", + in: "alpha bravo charlie delta echo foxtrot golf. short.", + max: 20, + want: "alpha bravo charlie…", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := previewText(tt.in, tt.max) + if got != tt.want { + t.Errorf("previewText(%q, %d) = %q, want %q", tt.in, tt.max, got, tt.want) + } + }) + } +}