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 }}
This commit is contained in:
Mathias Biilmann
2017-01-06 01:42:32 -08:00
committed by Bjørn Erik Pedersen
parent 9c19ef0f87
commit 2989c38245
5 changed files with 253 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -2188,6 +2188,7 @@ func initFuncMap() {
"title": title,
"time": asTime,
"trim": trim,
"truncate": truncate,
"upper": upper,
"urlize": helpers.CurrentPathSpec().URLize,
"where": where,

View File

@@ -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
`