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.
This commit is contained in:
@@ -100,6 +100,51 @@ func detailMeta(r store.SummaryRow) string {
|
|||||||
return strings.Join(parts, " · ")
|
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.
|
// videoURL builds the internal detail-page path for a video id.
|
||||||
func videoURL(videoID string) templ.SafeURL {
|
func videoURL(videoID string) templ.SafeURL {
|
||||||
return templ.SafeURL("/v/" + videoID)
|
return templ.SafeURL("/v/" + videoID)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user