summaryrefslogtreecommitdiffstats
path: root/tpl
diff options
context:
space:
mode:
authorMathias Biilmann <info@mathias-biilmann.net>2017-01-06 01:42:32 -0800
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>2017-01-06 10:42:32 +0100
commit2989c38245628e1ed0062f1a345da0693f4f294d (patch)
tree249d21374193c5e4af5545f2fc656ac09485107c /tpl
parent9c19ef0f871007470898eb640bd5d9efabd85a84 (diff)
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 }}
Diffstat (limited to 'tpl')
-rw-r--r--tpl/template_func_truncate.go156
-rw-r--r--tpl/template_func_truncate_test.go82
-rw-r--r--tpl/template_funcs.go1
-rw-r--r--tpl/template_funcs_test.go4
4 files changed, 243 insertions, 0 deletions
diff --git a/tpl/template_func_truncate.go b/tpl/template_func_truncate.go
new file mode 100644
index 000000000..b5886edae
--- /dev/null
+++ b/tpl/template_func_truncate.go
@@ -0,0 +1,156 @@
+// Copyright 2016 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package tpl
+
+import (
+ "errors"
+ "html"
+ "html/template"
+ "regexp"
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/spf13/cast"
+)
+
+var (
+ tagRE = regexp.MustCompile(`^<(/)?([^ ]+?)(?:(\s*/)| .*?)?>`)
+ htmlSinglets = map[string]bool{
+ "br": true, "col": true, "link": true,
+ "base": true, "img": true, "param": true,
+ "area": true, "hr": true, "input": true,
+ }
+)
+
+type htmlTag struct {
+ name string
+ pos int
+ openTag bool
+}
+
+func truncate(a interface{}, options ...interface{}) (template.HTML, error) {
+ length, err := cast.ToIntE(a)
+ if err != nil {
+ return "", err
+ }
+ var textParam interface{}
+ var ellipsis string
+
+ switch len(options) {
+ case 0:
+ return "", errors.New("truncate requires a length and a string")
+ case 1:
+ textParam = options[0]
+ ellipsis = " …"
+ case 2:
+ textParam = options[1]
+ ellipsis, err = cast.ToStringE(options[0])
+ if err != nil {
+ return "", errors.New("ellipsis must be a string")
+ }
+ if _, ok := options[0].(template.HTML); !ok {
+ ellipsis = html.EscapeString(ellipsis)
+ }
+ default:
+ return "", errors.New("too many arguments passed to truncate")
+ }
+ if err != nil {
+ return "", errors.New("text to truncate must be a string")
+ }
+ text, err := cast.ToStringE(textParam)
+ if err != nil {
+ return "", errors.New("text must be a string")
+ }
+
+ _, isHTML := textParam.(template.HTML)
+
+ if utf8.RuneCountInString(text) <= length {
+ if isHTML {
+ return template.HTML(text), nil
+ }
+ return template.HTML(html.EscapeString(text)), nil
+ }
+
+ tags := []htmlTag{}
+ var lastWordIndex, lastNonSpace, currentLen, endTextPos, nextTag int
+
+ for i, r := range text {
+ if i < nextTag {
+ continue
+ }
+
+ if isHTML {
+ // Make sure we keep tag of HTML tags
+ slice := text[i:]
+ m := tagRE.FindStringSubmatchIndex(slice)
+ if len(m) > 0 && m[0] == 0 {
+ nextTag = i + m[1]
+ tagname := slice[m[4]:m[5]]
+ lastWordIndex = lastNonSpace
+ _, singlet := htmlSinglets[tagname]
+ if !singlet && m[6] == -1 {
+ tags = append(tags, htmlTag{name: tagname, pos: i, openTag: m[2] == -1})
+ }
+
+ continue
+ }
+ }
+
+ currentLen++
+ if unicode.IsSpace(r) {
+ lastWordIndex = lastNonSpace
+ } else if unicode.In(r, unicode.Han, unicode.Hangul, unicode.Hiragana, unicode.Katakana) {
+ lastWordIndex = i
+ } else {
+ lastNonSpace = i + utf8.RuneLen(r)
+ }
+
+ if currentLen > length {
+ if lastWordIndex == 0 {
+ endTextPos = i
+ } else {
+ endTextPos = lastWordIndex
+ }
+ out := text[0:endTextPos]
+ if isHTML {
+ out += ellipsis
+ // Close out any open HTML tags
+ var currentTag *htmlTag
+ for i := len(tags) - 1; i >= 0; i-- {
+ tag := tags[i]
+ if tag.pos >= endTextPos || currentTag != nil {
+ if currentTag != nil && currentTag.name == tag.name {
+ currentTag = nil
+ }
+ continue
+ }
+
+ if tag.openTag {
+ out += ("</" + tag.name + ">")
+ } else {
+ currentTag = &tag
+ }
+ }
+
+ return template.HTML(out), nil
+ }
+ return template.HTML(html.EscapeString(out) + ellipsis), nil
+ }
+ }
+
+ if isHTML {
+ return template.HTML(text), nil
+ }
+ return template.HTML(html.EscapeString(text)), nil
+}
diff --git a/tpl/template_func_truncate_test.go b/tpl/template_func_truncate_test.go
new file mode 100644
index 000000000..95368f83e
--- /dev/null
+++ b/tpl/template_func_truncate_test.go
@@ -0,0 +1,82 @@
+// Copyright 2016 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package tpl
+
+import (
+ "html/template"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestTruncate(t *testing.T) {
+ var err error
+ cases := []struct {
+ v1 interface{}
+ v2 interface{}
+ v3 interface{}
+ want interface{}
+ isErr bool
+ }{
+ {10, "I am a test sentence", nil, template.HTML("I am a …"), false},
+ {10, "", "I am a test sentence", template.HTML("I am a"), false},
+ {10, "", "a b c d e f g h i j k", template.HTML("a b c d e"), false},
+ {12, "", "<b>Should be escaped</b>", template.HTML("&lt;b&gt;Should be"), false},
+ {10, template.HTML(" <a href='#'>Read more</a>"), "I am a test sentence", template.HTML("I am a <a href='#'>Read more</a>"), false},
+ {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},
+ {10, "IamanextremelylongwordthatjustgoesonandonandonjusttoannoyyoualmostasifIwaswritteninGermanActuallyIbettheresagermanwordforthis", nil, template.HTML("Iamanextre …"), false},
+ {10, template.HTML("<p>IamanextremelylongwordthatjustgoesonandonandonjusttoannoyyoualmostasifIwaswritteninGermanActuallyIbettheresagermanwordforthis</p>"), nil, template.HTML("<p>Iamanextre …</p>"), false},
+ {13, template.HTML("With <a href=\"/markdown\">Markdown</a> inside."), nil, template.HTML("With <a href=\"/markdown\">Markdown …</a>"), false},
+ {14, "Hello中国 Good 好的", nil, template.HTML("Hello中国 Good 好 …"), false},
+ {15, "", template.HTML("A <br> tag that's not closed"), template.HTML("A <br> tag that's"), false},
+ {14, template.HTML("<p>Hello中国 Good 好的</p>"), nil, template.HTML("<p>Hello中国 Good 好 …</p>"), false},
+ {2, template.HTML("<p>P1</p><p>P2</p>"), nil, template.HTML("<p>P1 …</p>"), false},
+ {3, template.HTML(strings.Repeat("<p>P</p>", 20)), nil, template.HTML("<p>P</p><p>P</p><p>P …</p>"), false},
+ {18, template.HTML("<p>test <b>hello</b> test something</p>"), nil, template.HTML("<p>test <b>hello</b> test …</p>"), false},
+ {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},
+ {10, nil, nil, template.HTML(""), true},
+ {nil, nil, nil, template.HTML(""), true},
+ }
+ for i, c := range cases {
+ var result template.HTML
+ if c.v2 == nil {
+ result, err = truncate(c.v1)
+ } else if c.v3 == nil {
+ result, err = truncate(c.v1, c.v2)
+ } else {
+ result, err = truncate(c.v1, c.v2, c.v3)
+ }
+
+ if c.isErr {
+ if err == nil {
+ t.Errorf("[%d] Slice didn't return an expected error", i)
+ }
+ } else {
+ if err != nil {
+ t.Errorf("[%d] failed: %s", i, err)
+ continue
+ }
+ if !reflect.DeepEqual(result, c.want) {
+ t.Errorf("[%d] got '%s' but expected '%s'", i, result, c.want)
+ }
+ }
+ }
+
+ // Too many arguments
+ _, err = truncate(10, " ...", "I am a test sentence", "wrong")
+ if err == nil {
+ t.Errorf("Should have errored")
+ }
+
+}
diff --git a/tpl/template_funcs.go b/tpl/template_funcs.go
index fd3337454..596902fcd 100644
--- a/tpl/template_funcs.go
+++ b/tpl/template_funcs.go
@@ -2188,6 +2188,7 @@ func initFuncMap() {
"title": title,
"time": asTime,
"trim": trim,
+ "truncate": truncate,
"upper": upper,
"urlize": helpers.CurrentPathSpec().URLize,
"where": where,
diff --git a/tpl/template_funcs_test.go b/tpl/template_funcs_test.go
index 37f075a99..fd51e3a1a 100644
--- a/tpl/template_funcs_test.go
+++ b/tpl/template_funcs_test.go
@@ -157,6 +157,8 @@ substr: {{substr "BatMan" 3 3}}
title: {{title "Bat man"}}
time: {{ (time "2015-01-21").Year }}
trim: {{ trim "++Batman--" "+-" }}
+truncate: {{ "this is a very long text" | truncate 10 " ..." }}
+truncate: {{ "With [Markdown](/markdown) inside." | markdownify | truncate 14 }}
upper: {{upper "BatMan"}}
urlize: {{ "Bat Man" | urlize }}
`
@@ -228,6 +230,8 @@ substr: Man
title: Bat Man
time: 2015
trim: Batman
+truncate: this is a ...
+truncate: With <a href="/markdown">Markdown …</a>
upper: BATMAN
urlize: bat-man
`