Skip to content

Commit 2989c38

Browse files
biilmannbep
authored andcommitted
tpl: 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 9c19ef0 commit 2989c38

File tree

5 files changed

+253
-0
lines changed

5 files changed

+253
-0
lines changed

‎docs/content/templates/functions.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,16 @@ 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 leaving unclosed HTML tags. Since Go templates are HTML-aware, truncate will handle normal strings vs HTML strings intelligently. It's important to note that if you have a raw string that contains HTML tags that you want treated as HTML, you will need to convert the string to HTML using the safeHTML template function before sending the value to truncate; otherwise, the HTML tags will be escaped by truncate.
668+
669+
e.g.
670+
671+
* `{{ "this is a text" | truncate 10 " ..." }}``this is a ...`
672+
* `{{ "<em>Keep my HTML</em>" | safeHTML | truncate 10 }}``<em>Keep my …</em>`
673+
* `{{ "With [Markdown](#markdown) inside." | markdownify | truncate 10 }}``With <a href='#markdown'>Markdown …</a>`
674+
665675
### split
666676

667677
Split a string into substrings separated by a delimiter.

‎tpl/template_func_truncate.go‎

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Copyright 2016 The Hugo Authors. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package tpl
15+
16+
import (
17+
"errors"
18+
"html"
19+
"html/template"
20+
"regexp"
21+
"unicode"
22+
"unicode/utf8"
23+
24+
"github.com/spf13/cast"
25+
)
26+
27+
var (
28+
tagRE = regexp.MustCompile(`^<(/)?([^ ]+?)(?:(\s*/)| .*?)?>`)
29+
htmlSinglets = map[string]bool{
30+
"br": true, "col": true, "link": true,
31+
"base": true, "img": true, "param": true,
32+
"area": true, "hr": true, "input": true,
33+
}
34+
)
35+
36+
type htmlTag struct {
37+
name string
38+
pos int
39+
openTag bool
40+
}
41+
42+
func truncate(a interface{}, options ...interface{}) (template.HTML, error) {
43+
length, err := cast.ToIntE(a)
44+
if err != nil {
45+
return "", err
46+
}
47+
var textParam interface{}
48+
var ellipsis string
49+
50+
switch len(options) {
51+
case 0:
52+
return "", errors.New("truncate requires a length and a string")
53+
case 1:
54+
textParam = options[0]
55+
ellipsis = " …"
56+
case 2:
57+
textParam = options[1]
58+
ellipsis, err = cast.ToStringE(options[0])
59+
if err != nil {
60+
return "", errors.New("ellipsis must be a string")
61+
}
62+
if _, ok := options[0].(template.HTML); !ok {
63+
ellipsis = html.EscapeString(ellipsis)
64+
}
65+
default:
66+
return "", errors.New("too many arguments passed to truncate")
67+
}
68+
if err != nil {
69+
return "", errors.New("text to truncate must be a string")
70+
}
71+
text, err := cast.ToStringE(textParam)
72+
if err != nil {
73+
return "", errors.New("text must be a string")
74+
}
75+
76+
_, isHTML := textParam.(template.HTML)
77+
78+
if utf8.RuneCountInString(text) <= length {
79+
if isHTML {
80+
return template.HTML(text), nil
81+
}
82+
return template.HTML(html.EscapeString(text)), nil
83+
}
84+
85+
tags := []htmlTag{}
86+
var lastWordIndex, lastNonSpace, currentLen, endTextPos, nextTag int
87+
88+
for i, r := range text {
89+
if i < nextTag {
90+
continue
91+
}
92+
93+
if isHTML {
94+
// Make sure we keep tag of HTML tags
95+
slice := text[i:]
96+
m := tagRE.FindStringSubmatchIndex(slice)
97+
if len(m) > 0 && m[0] == 0 {
98+
nextTag = i + m[1]
99+
tagname := slice[m[4]:m[5]]
100+
lastWordIndex = lastNonSpace
101+
_, singlet := htmlSinglets[tagname]
102+
if !singlet && m[6] == -1 {
103+
tags = append(tags, htmlTag{name: tagname, pos: i, openTag: m[2] == -1})
104+
}
105+
106+
continue
107+
}
108+
}
109+
110+
currentLen++
111+
if unicode.IsSpace(r) {
112+
lastWordIndex = lastNonSpace
113+
} else if unicode.In(r, unicode.Han, unicode.Hangul, unicode.Hiragana, unicode.Katakana) {
114+
lastWordIndex = i
115+
} else {
116+
lastNonSpace = i + utf8.RuneLen(r)
117+
}
118+
119+
if currentLen > length {
120+
if lastWordIndex == 0 {
121+
endTextPos = i
122+
} else {
123+
endTextPos = lastWordIndex
124+
}
125+
out := text[0:endTextPos]
126+
if isHTML {
127+
out += ellipsis
128+
// Close out any open HTML tags
129+
var currentTag *htmlTag
130+
for i := len(tags) - 1; i >= 0; i-- {
131+
tag := tags[i]
132+
if tag.pos >= endTextPos || currentTag != nil {
133+
if currentTag != nil && currentTag.name == tag.name {
134+
currentTag = nil
135+
}
136+
continue
137+
}
138+
139+
if tag.openTag {
140+
out += ("</" + tag.name + ">")
141+
} else {
142+
currentTag = &tag
143+
}
144+
}
145+
146+
return template.HTML(out), nil
147+
}
148+
return template.HTML(html.EscapeString(out) + ellipsis), nil
149+
}
150+
}
151+
152+
if isHTML {
153+
return template.HTML(text), nil
154+
}
155+
return template.HTML(html.EscapeString(text)), nil
156+
}

‎tpl/template_func_truncate_test.go‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright 2016 The Hugo Authors. All rights reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package tpl
15+
16+
import (
17+
"html/template"
18+
"reflect"
19+
"strings"
20+
"testing"
21+
)
22+
23+
func TestTruncate(t *testing.T) {
24+
var err error
25+
cases := []struct {
26+
v1 interface{}
27+
v2 interface{}
28+
v3 interface{}
29+
want interface{}
30+
isErr bool
31+
}{
32+
{10, "I am a test sentence", nil, template.HTML("I am a …"), false},
33+
{10, "", "I am a test sentence", template.HTML("I am a"), false},
34+
{10, "", "a b c d e f g h i j k", template.HTML("a b c d e"), false},
35+
{12, "", "<b>Should be escaped</b>", template.HTML("&lt;b&gt;Should be"), false},
36+
{10, template.HTML(" <a href='#'>Read more</a>"), "I am a test sentence", template.HTML("I am a <a href='#'>Read more</a>"), false},
37+
{20, template.HTML("I have a <a href='/markdown'>Markdown link</a> inside."), nil, template.HTML("I have a <a href='/markdown'>Markdown …</a>"), false},
38+
{10, "IamanextremelylongwordthatjustgoesonandonandonjusttoannoyyoualmostasifIwaswritteninGermanActuallyIbettheresagermanwordforthis", nil, template.HTML("Iamanextre …"), false},
39+
{10, template.HTML("<p>IamanextremelylongwordthatjustgoesonandonandonjusttoannoyyoualmostasifIwaswritteninGermanActuallyIbettheresagermanwordforthis</p>"), nil, template.HTML("<p>Iamanextre …</p>"), false},
40+
{13, template.HTML("With <a href=\"/markdown\">Markdown</a> inside."), nil, template.HTML("With <a href=\"/markdown\">Markdown …</a>"), false},
41+
{14, "Hello中国 Good 好的", nil, template.HTML("Hello中国 Good 好 …"), false},
42+
{15, "", template.HTML("A <br> tag that's not closed"), template.HTML("A <br> tag that's"), false},
43+
{14, template.HTML("<p>Hello中国 Good 好的</p>"), nil, template.HTML("<p>Hello中国 Good 好 …</p>"), false},
44+
{2, template.HTML("<p>P1</p><p>P2</p>"), nil, template.HTML("<p>P1 …</p>"), false},
45+
{3, template.HTML(strings.Repeat("<p>P</p>", 20)), nil, template.HTML("<p>P</p><p>P</p><p>P …</p>"), false},
46+
{18, template.HTML("<p>test <b>hello</b> test something</p>"), nil, template.HTML("<p>test <b>hello</b> test …</p>"), false},
47+
{4, template.HTML("<p>a<b><i>b</b>c d e</p>"), nil, template.HTML("<p>a<b><i>b</b>c …</p>"), false},
48+
{10, nil, nil, template.HTML(""), true},
49+
{nil, nil, nil, template.HTML(""), true},
50+
}
51+
for i, c := range cases {
52+
var result template.HTML
53+
if c.v2 == nil {
54+
result, err = truncate(c.v1)
55+
} else if c.v3 == nil {
56+
result, err = truncate(c.v1, c.v2)
57+
} else {
58+
result, err = truncate(c.v1, c.v2, c.v3)
59+
}
60+
61+
if c.isErr {
62+
if err == nil {
63+
t.Errorf("[%d] Slice didn't return an expected error", i)
64+
}
65+
} else {
66+
if err != nil {
67+
t.Errorf("[%d] failed: %s", i, err)
68+
continue
69+
}
70+
if !reflect.DeepEqual(result, c.want) {
71+
t.Errorf("[%d] got '%s' but expected '%s'", i, result, c.want)
72+
}
73+
}
74+
}
75+
76+
// Too many arguments
77+
_, err = truncate(10, " ...", "I am a test sentence", "wrong")
78+
if err == nil {
79+
t.Errorf("Should have errored")
80+
}
81+
82+
}

‎tpl/template_funcs.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2188,6 +2188,7 @@ func initFuncMap() {
21882188
"title": title,
21892189
"time": asTime,
21902190
"trim": trim,
2191+
"truncate": truncate,
21912192
"upper": upper,
21922193
"urlize": helpers.CurrentPathSpec().URLize,
21932194
"where": where,

‎tpl/template_funcs_test.go‎

Lines changed: 4 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 14 }}
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
`

0 commit comments

Comments
 (0)