Skip to content

Commit a6caf55

Browse files
committed
Add truncate template function
This commit adds a truncate template function for safely truncating text without breaking words. The truncate function is HTML aware, so if the input text is a template.HTML it will be truncated without leaving broken or unclosed HTML tags. {{ "this is a very long text" | truncate 10 " ..." }} {{ "With [Markdown](/markdown) inside." | markdownify | truncate 10 }}
1 parent 19b6fdf commit a6caf55

File tree

3 files changed

+198
-1
lines changed

3 files changed

+198
-1
lines changed

‎docs/content/templates/functions.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,15 @@ e.g.
662662
* `{{slicestr "BatMan" 3}}` → "Man"
663663
* `{{slicestr "BatMan" 0 3}}` → "Bat"
664664

665+
### truncate
666+
667+
Truncate a text to a max length without cutting words or HTML tags in half. Since go templates are HTML aware, truncate will handle normal strings vs HTML strings intelligently.
668+
669+
e.q.
670+
671+
* `{{ "this is a text" | truncate 10 " ..." }}` → this is a ...
672+
* `{{ "With [Markdown](#markdown) inside." | markdownify | truncate 10 }}` → With <a href='#markdown'>Markdown …</a>
673+
665674
### split
666675

667676
Split a string into substrings separated by a delimiter.

‎tpl/template_funcs.go‎

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import (
3838
"strings"
3939
"sync"
4040
"time"
41+
"unicode"
4142
"unicode/utf8"
4243

4344
"github.com/bep/inflect"
@@ -55,7 +56,14 @@ import (
5556
)
5657

5758
var (
58-
funcMap template.FuncMap
59+
funcMap template.FuncMap
60+
tagRE = regexp.MustCompile(`(?s)<(/)?([^ ]+?)(?:(\s*/)| .*?)?>`)
61+
htmlRE = regexp.MustCompile(`(?s)<.*?>|((?:\w[-\w]*|&.*?;)+)`)
62+
htmlSinglets = map[string]bool{
63+
"br": true, "col": true, "link": true,
64+
"base": true, "img": true, "param": true,
65+
"area": true, "hr": true, "input": true,
66+
}
5967
)
6068

6169
// eq returns the boolean truth of arg1 == arg2.
@@ -239,6 +247,130 @@ func slicestr(a interface{}, startEnd ...interface{}) (string, error) {
239247

240248
}
241249

250+
func truncate(a interface{}, options ...interface{}) (template.HTML, error) {
251+
length, err := cast.ToIntE(a)
252+
if err != nil {
253+
return "", err
254+
}
255+
var textParam interface{}
256+
var ellipsis template.HTML
257+
258+
switch len(options) {
259+
case 0:
260+
return "", errors.New("truncate requires a length and a string")
261+
case 1:
262+
textParam = options[0]
263+
ellipsis = " …"
264+
case 2:
265+
textParam = options[1]
266+
var ok bool
267+
if ellipsis, ok = options[0].(template.HTML); !ok {
268+
s, e := cast.ToStringE(options[0])
269+
if e != nil {
270+
return "", errors.New("ellipsis must be a string")
271+
}
272+
ellipsis = template.HTML(html.EscapeString(s))
273+
}
274+
default:
275+
return "", errors.New("too many arguments passed to truncate")
276+
}
277+
if err != nil {
278+
return "", errors.New("text to truncate must be a string")
279+
}
280+
text, err := cast.ToStringE(textParam)
281+
if err != nil {
282+
return "", errors.New("text must be a string")
283+
}
284+
285+
if html, ok := textParam.(template.HTML); ok {
286+
return truncateHTML(length, ellipsis, html)
287+
}
288+
289+
if len(text) <= length {
290+
return template.HTML(html.EscapeString(text)), nil
291+
}
292+
293+
var lastWordIndex int
294+
var lastNonSpace int
295+
for i, r := range text {
296+
if unicode.IsSpace(r) {
297+
lastWordIndex = lastNonSpace
298+
} else {
299+
lastNonSpace = i
300+
}
301+
if i >= length {
302+
return template.HTML(html.EscapeString(text[0:lastWordIndex+1])) + ellipsis, nil
303+
}
304+
}
305+
306+
return template.HTML(html.EscapeString(text)), nil
307+
}
308+
309+
func truncateHTML(length int, ellipsis, text template.HTML) (template.HTML, error) {
310+
if len(text) <= length {
311+
return text, nil
312+
}
313+
314+
var pos, endTextPos, currentLen int
315+
openTags := []string{}
316+
317+
for currentLen < length {
318+
slice := string(text[pos:])
319+
m := htmlRE.FindStringSubmatchIndex(slice)
320+
if len(m) == 0 {
321+
// Checked through whole string
322+
break
323+
}
324+
325+
pos += m[1]
326+
if len(m) == 4 && m[3]-m[2] > 0 {
327+
// It's an actual non-HTML word or char
328+
currentLen += (m[3] - m[2]) + 1 // 1 space between each word
329+
if currentLen >= length {
330+
endTextPos = pos
331+
}
332+
continue
333+
}
334+
335+
tag := tagRE.FindStringSubmatch(slice[m[0]:m[1]])
336+
if len(tag) == 0 || currentLen >= length {
337+
// Don't worry about non tags or tags after our truncate point
338+
continue
339+
}
340+
closingTag := tag[1]
341+
tagname := strings.ToLower(tag[2])
342+
selfClosing := tag[3]
343+
344+
_, singlet := htmlSinglets[tagname]
345+
if !singlet && selfClosing == "" {
346+
if closingTag == "" {
347+
// Add it to the start of the open tags list
348+
openTags = append([]string{tagname}, openTags...)
349+
} else {
350+
for i, tag := range openTags {
351+
if tag == tagname {
352+
// SGML: An end tag closes, back to the matching start tag,
353+
// all unclosed intervening start tags with omitted end tags
354+
openTags = openTags[i+1:]
355+
break
356+
}
357+
}
358+
}
359+
}
360+
}
361+
362+
if currentLen < length {
363+
return text, nil
364+
}
365+
366+
out := text[0:endTextPos]
367+
out += ellipsis
368+
for _, tag := range openTags {
369+
out += ("</" + template.HTML(tag) + ">")
370+
}
371+
return out, nil
372+
}
373+
242374
// hasPrefix tests whether the input s begins with prefix.
243375
func hasPrefix(s, prefix interface{}) (bool, error) {
244376
ss, err := cast.ToStringE(s)
@@ -2188,6 +2320,7 @@ func initFuncMap() {
21882320
"title": title,
21892321
"time": asTime,
21902322
"trim": trim,
2323+
"truncate": truncate,
21912324
"upper": upper,
21922325
"urlize": helpers.CurrentPathSpec().URLize,
21932326
"where": where,

‎tpl/template_funcs_test.go‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ substr: {{substr "BatMan" 3 3}}
157157
title: {{title "Bat man"}}
158158
time: {{ (time "2015-01-21").Year }}
159159
trim: {{ trim "++Batman--" "+-" }}
160+
truncate: {{ "this is a very long text" | truncate 10 " ..." }}
161+
truncate: {{ "With [Markdown](/markdown) inside." | markdownify | truncate 10 }}
160162
upper: {{upper "BatMan"}}
161163
urlize: {{ "Bat Man" | urlize }}
162164
`
@@ -228,6 +230,8 @@ substr: Man
228230
title: Bat Man
229231
time: 2015
230232
trim: Batman
233+
truncate: this is a ...
234+
truncate: With <a href="/markdown">Markdown …</a>
231235
upper: BATMAN
232236
urlize: bat-man
233237
`
@@ -815,6 +819,57 @@ func TestSlicestr(t *testing.T) {
815819
}
816820
}
817821

822+
func TestTruncate(t *testing.T) {
823+
var err error
824+
cases := []struct {
825+
v1 interface{}
826+
v2 interface{}
827+
v3 interface{}
828+
want interface{}
829+
isErr bool
830+
}{
831+
{10, "I am a test sentence", nil, template.HTML("I am a …"), false},
832+
{10, "", "I am a test sentence", template.HTML("I am a"), false},
833+
{10, "", "a b c d e f g h i j k", template.HTML("a b c d e"), false},
834+
{12, "", "<b>Should be escaped</b>", template.HTML("&lt;b&gt;Should be"), false},
835+
{10, template.HTML(" <a href='#'>Read more</a>"), "I am a test sentence", template.HTML("I am a <a href='#'>Read more</a>"), false},
836+
{10, template.HTML("I have a <a href='/markdown'>Markdown</a> link inside."), nil, template.HTML("I have a <a href='/markdown'>Markdown …</a>"), false},
837+
{10, nil, nil, template.HTML(""), true},
838+
{nil, nil, nil, template.HTML(""), true},
839+
}
840+
for i, c := range cases {
841+
var result template.HTML
842+
if c.v2 == nil {
843+
result, err = truncate(c.v1)
844+
} else if c.v3 == nil {
845+
result, err = truncate(c.v1, c.v2)
846+
} else {
847+
result, err = truncate(c.v1, c.v2, c.v3)
848+
}
849+
850+
if c.isErr {
851+
if err == nil {
852+
t.Errorf("[%d] Slice didn't return an expected error", i)
853+
}
854+
} else {
855+
if err != nil {
856+
t.Errorf("[%d] failed: %s", i, err)
857+
continue
858+
}
859+
if !reflect.DeepEqual(result, c.want) {
860+
t.Errorf("[%d] got '%s' but expected '%s'", i, result, c.want)
861+
}
862+
}
863+
}
864+
865+
// Too many arguments
866+
_, err = truncate(10, " ...", "I am a test sentence", "wrong")
867+
if err == nil {
868+
t.Errorf("Should have errored")
869+
}
870+
871+
}
872+
818873
func TestHasPrefix(t *testing.T) {
819874
cases := []struct {
820875
s interface{}

0 commit comments

Comments
 (0)