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, " · ")
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
Reference in New Issue
Block a user