mirror of
https://github.com/gohugoio/hugo.git
synced 2025-08-28 22:19:59 +02:00
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:
committed by
Bjørn Erik Pedersen
parent
9c19ef0f87
commit
2989c38245
156
tpl/template_func_truncate.go
Normal file
156
tpl/template_func_truncate.go
Normal 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
|
||||
}
|
82
tpl/template_func_truncate_test.go
Normal file
82
tpl/template_func_truncate_test.go
Normal 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("<b>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")
|
||||
}
|
||||
|
||||
}
|
@@ -2188,6 +2188,7 @@ func initFuncMap() {
|
||||
"title": title,
|
||||
"time": asTime,
|
||||
"trim": trim,
|
||||
"truncate": truncate,
|
||||
"upper": upper,
|
||||
"urlize": helpers.CurrentPathSpec().URLize,
|
||||
"where": where,
|
||||
|
@@ -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
|
||||
`
|
||||
|
Reference in New Issue
Block a user