tpl: Add template function namespaces

This commit moves almost all of the template functions into separate
packages under tpl/ and adds a namespace framework.  All changes should
be backward compatible for end users, as all existing function names in
the template funcMap are left intact.

Seq and DoArithmatic have been moved out of the helpers package and into
template namespaces.

Most of the tests involved have been refactored, and many new tests have
been written.  There's still work to do, but this is a big improvement.

I got a little overzealous and added some new functions along the way:

- strings.Contains
- strings.ContainsAny
- strings.HasSuffix
- strings.TrimPrefix
- strings.TrimSuffix

Documentation is forthcoming.

Fixes #3042
This commit is contained in:
Cameron Moore
2017-03-13 17:55:02 -05:00
committed by Bjørn Erik Pedersen
parent 154e18ddb9
commit de7c32a1a8
48 changed files with 7069 additions and 5268 deletions

109
tpl/strings/regexp.go Normal file
View File

@@ -0,0 +1,109 @@
// Copyright 2017 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 strings
import (
"regexp"
"sync"
"github.com/spf13/cast"
)
// FindRE returns a list of strings that match the regular expression. By default all matches
// will be included. The number of matches can be limited with an optional third parameter.
func (ns *Namespace) FindRE(expr string, content interface{}, limit ...interface{}) ([]string, error) {
re, err := reCache.Get(expr)
if err != nil {
return nil, err
}
conv, err := cast.ToStringE(content)
if err != nil {
return nil, err
}
if len(limit) == 0 {
return re.FindAllString(conv, -1), nil
}
lim, err := cast.ToIntE(limit[0])
if err != nil {
return nil, err
}
return re.FindAllString(conv, lim), nil
}
// ReplaceRE returns a copy of s, replacing all matches of the regular
// expression pattern with the replacement text repl.
func (ns *Namespace) ReplaceRE(pattern, repl, s interface{}) (_ string, err error) {
sp, err := cast.ToStringE(pattern)
if err != nil {
return
}
sr, err := cast.ToStringE(repl)
if err != nil {
return
}
ss, err := cast.ToStringE(s)
if err != nil {
return
}
re, err := reCache.Get(sp)
if err != nil {
return "", err
}
return re.ReplaceAllString(ss, sr), nil
}
// regexpCache represents a cache of regexp objects protected by a mutex.
type regexpCache struct {
mu sync.RWMutex
re map[string]*regexp.Regexp
}
// Get retrieves a regexp object from the cache based upon the pattern.
// If the pattern is not found in the cache, create one
func (rc *regexpCache) Get(pattern string) (re *regexp.Regexp, err error) {
var ok bool
if re, ok = rc.get(pattern); !ok {
re, err = regexp.Compile(pattern)
if err != nil {
return nil, err
}
rc.set(pattern, re)
}
return re, nil
}
func (rc *regexpCache) get(key string) (re *regexp.Regexp, ok bool) {
rc.mu.RLock()
re, ok = rc.re[key]
rc.mu.RUnlock()
return
}
func (rc *regexpCache) set(key string, re *regexp.Regexp) {
rc.mu.Lock()
rc.re[key] = re
rc.mu.Unlock()
}
var reCache = regexpCache{re: make(map[string]*regexp.Regexp)}

View File

@@ -0,0 +1,86 @@
// Copyright 2017 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 strings
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFindRE(t *testing.T) {
t.Parallel()
for i, test := range []struct {
expr string
content interface{}
limit interface{}
expect interface{}
}{
{"[G|g]o", "Hugo is a static site generator written in Go.", 2, []string{"go", "Go"}},
{"[G|g]o", "Hugo is a static site generator written in Go.", -1, []string{"go", "Go"}},
{"[G|g]o", "Hugo is a static site generator written in Go.", 1, []string{"go"}},
{"[G|g]o", "Hugo is a static site generator written in Go.", "1", []string{"go"}},
{"[G|g]o", "Hugo is a static site generator written in Go.", nil, []string(nil)},
// errors
{"[G|go", "Hugo is a static site generator written in Go.", nil, false},
{"[G|g]o", t, nil, false},
} {
errMsg := fmt.Sprintf("[%d] %v", i, test)
result, err := ns.FindRE(test.expr, test.content, test.limit)
if b, ok := test.expect.(bool); ok && !b {
require.Error(t, err, errMsg)
continue
}
require.NoError(t, err, errMsg)
assert.Equal(t, test.expect, result, errMsg)
}
}
func TestReplaceRE(t *testing.T) {
t.Parallel()
for i, test := range []struct {
pattern interface{}
repl interface{}
s interface{}
expect interface{}
}{
{"^https?://([^/]+).*", "$1", "http://gohugo.io/docs", "gohugo.io"},
{"^https?://([^/]+).*", "$2", "http://gohugo.io/docs", ""},
{"(ab)", "AB", "aabbaab", "aABbaAB"},
// errors
{"(ab", "AB", "aabb", false}, // invalid re
{tstNoStringer{}, "$2", "http://gohugo.io/docs", false},
{"^https?://([^/]+).*", tstNoStringer{}, "http://gohugo.io/docs", false},
{"^https?://([^/]+).*", "$2", tstNoStringer{}, false},
} {
errMsg := fmt.Sprintf("[%d] %v", i, test)
result, err := ns.ReplaceRE(test.pattern, test.repl, test.s)
if b, ok := test.expect.(bool); ok && !b {
require.Error(t, err, errMsg)
continue
}
require.NoError(t, err, errMsg)
assert.Equal(t, test.expect, result, errMsg)
}
}

380
tpl/strings/strings.go Normal file
View File

@@ -0,0 +1,380 @@
// Copyright 2017 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 strings
import (
"errors"
"fmt"
"html/template"
_strings "strings"
"unicode/utf8"
"github.com/spf13/cast"
"github.com/spf13/hugo/deps"
"github.com/spf13/hugo/helpers"
)
// New returns a new instance of the strings-namespaced template functions.
func New(d *deps.Deps) *Namespace {
return &Namespace{deps: d}
}
// Namespace provides template functions for the "strings" namespace.
// Most functions mimic the Go stdlib, but the order of the parameters may be
// different to ease their use in the Go template system.
type Namespace struct {
deps *deps.Deps
}
// Namespace returns a pointer to the current namespace instance.
func (ns *Namespace) Namespace() *Namespace { return ns }
// CountRunes returns the number of runes in s, excluding whitepace.
func (ns *Namespace) CountRunes(s interface{}) (int, error) {
ss, err := cast.ToStringE(s)
if err != nil {
return 0, fmt.Errorf("Failed to convert content to string: %s", err)
}
counter := 0
for _, r := range helpers.StripHTML(ss) {
if !helpers.IsWhitespace(r) {
counter++
}
}
return counter, nil
}
// CountWords returns the approximate word count in s.
func (ns *Namespace) CountWords(s interface{}) (int, error) {
ss, err := cast.ToStringE(s)
if err != nil {
return 0, fmt.Errorf("Failed to convert content to string: %s", err)
}
counter := 0
for _, word := range _strings.Fields(helpers.StripHTML(ss)) {
runeCount := utf8.RuneCountInString(word)
if len(word) == runeCount {
counter++
} else {
counter += runeCount
}
}
return counter, nil
}
// Chomp returns a copy of s with all trailing newline characters removed.
func (ns *Namespace) Chomp(s interface{}) (template.HTML, error) {
ss, err := cast.ToStringE(s)
if err != nil {
return "", err
}
return template.HTML(_strings.TrimRight(ss, "\r\n")), nil
}
// Contains reports whether substr is in s.
func (ns *Namespace) Contains(s, substr interface{}) (bool, error) {
ss, err := cast.ToStringE(s)
if err != nil {
return false, err
}
su, err := cast.ToStringE(substr)
if err != nil {
return false, err
}
return _strings.Contains(ss, su), nil
}
// ContainsAny reports whether any Unicode code points in chars are within s.
func (ns *Namespace) ContainsAny(s, chars interface{}) (bool, error) {
ss, err := cast.ToStringE(s)
if err != nil {
return false, err
}
sc, err := cast.ToStringE(chars)
if err != nil {
return false, err
}
return _strings.ContainsAny(ss, sc), nil
}
// HasPrefix tests whether the input s begins with prefix.
func (ns *Namespace) HasPrefix(s, prefix interface{}) (bool, error) {
ss, err := cast.ToStringE(s)
if err != nil {
return false, err
}
sx, err := cast.ToStringE(prefix)
if err != nil {
return false, err
}
return _strings.HasPrefix(ss, sx), nil
}
// HasSuffix tests whether the input s begins with suffix.
func (ns *Namespace) HasSuffix(s, suffix interface{}) (bool, error) {
ss, err := cast.ToStringE(s)
if err != nil {
return false, err
}
sx, err := cast.ToStringE(suffix)
if err != nil {
return false, err
}
return _strings.HasSuffix(ss, sx), nil
}
// Replace returns a copy of the string s with all occurrences of old replaced
// with new.
func (ns *Namespace) Replace(s, old, new interface{}) (string, error) {
ss, err := cast.ToStringE(s)
if err != nil {
return "", err
}
so, err := cast.ToStringE(old)
if err != nil {
return "", err
}
sn, err := cast.ToStringE(new)
if err != nil {
return "", err
}
return _strings.Replace(ss, so, sn, -1), nil
}
// SliceString slices a string by specifying a half-open range with
// two indices, start and end. 1 and 4 creates a slice including elements 1 through 3.
// The end index can be omitted, it defaults to the string's length.
func (ns *Namespace) SliceString(a interface{}, startEnd ...interface{}) (string, error) {
aStr, err := cast.ToStringE(a)
if err != nil {
return "", err
}
var argStart, argEnd int
argNum := len(startEnd)
if argNum > 0 {
if argStart, err = cast.ToIntE(startEnd[0]); err != nil {
return "", errors.New("start argument must be integer")
}
}
if argNum > 1 {
if argEnd, err = cast.ToIntE(startEnd[1]); err != nil {
return "", errors.New("end argument must be integer")
}
}
if argNum > 2 {
return "", errors.New("too many arguments")
}
asRunes := []rune(aStr)
if argNum > 0 && (argStart < 0 || argStart >= len(asRunes)) {
return "", errors.New("slice bounds out of range")
}
if argNum == 2 {
if argEnd < 0 || argEnd > len(asRunes) {
return "", errors.New("slice bounds out of range")
}
return string(asRunes[argStart:argEnd]), nil
} else if argNum == 1 {
return string(asRunes[argStart:]), nil
} else {
return string(asRunes[:]), nil
}
}
// Split slices an input string into all substrings separated by delimiter.
func (ns *Namespace) Split(a interface{}, delimiter string) ([]string, error) {
aStr, err := cast.ToStringE(a)
if err != nil {
return []string{}, err
}
return _strings.Split(aStr, delimiter), nil
}
// Substr extracts parts of a string, beginning at the character at the specified
// position, and returns the specified number of characters.
//
// It normally takes two parameters: start and length.
// It can also take one parameter: start, i.e. length is omitted, in which case
// the substring starting from start until the end of the string will be returned.
//
// To extract characters from the end of the string, use a negative start number.
//
// In addition, borrowing from the extended behavior described at http://php.net/substr,
// if length is given and is negative, then that many characters will be omitted from
// the end of string.
func (ns *Namespace) Substr(a interface{}, nums ...interface{}) (string, error) {
aStr, err := cast.ToStringE(a)
if err != nil {
return "", err
}
var start, length int
asRunes := []rune(aStr)
switch len(nums) {
case 0:
return "", errors.New("too less arguments")
case 1:
if start, err = cast.ToIntE(nums[0]); err != nil {
return "", errors.New("start argument must be integer")
}
length = len(asRunes)
case 2:
if start, err = cast.ToIntE(nums[0]); err != nil {
return "", errors.New("start argument must be integer")
}
if length, err = cast.ToIntE(nums[1]); err != nil {
return "", errors.New("length argument must be integer")
}
default:
return "", errors.New("too many arguments")
}
if start < -len(asRunes) {
start = 0
}
if start > len(asRunes) {
return "", fmt.Errorf("start position out of bounds for %d-byte string", len(aStr))
}
var s, e int
if start >= 0 && length >= 0 {
s = start
e = start + length
} else if start < 0 && length >= 0 {
s = len(asRunes) + start - length + 1
e = len(asRunes) + start + 1
} else if start >= 0 && length < 0 {
s = start
e = len(asRunes) + length
} else {
s = len(asRunes) + start
e = len(asRunes) + length
}
if s > e {
return "", fmt.Errorf("calculated start position greater than end position: %d > %d", s, e)
}
if e > len(asRunes) {
e = len(asRunes)
}
return string(asRunes[s:e]), nil
}
// Title returns a copy of the input s with all Unicode letters that begin words
// mapped to their title case.
func (ns *Namespace) Title(s interface{}) (string, error) {
ss, err := cast.ToStringE(s)
if err != nil {
return "", err
}
return _strings.Title(ss), nil
}
// ToLower returns a copy of the input s with all Unicode letters mapped to their
// lower case.
func (ns *Namespace) ToLower(s interface{}) (string, error) {
ss, err := cast.ToStringE(s)
if err != nil {
return "", err
}
return _strings.ToLower(ss), nil
}
// ToUpper returns a copy of the input s with all Unicode letters mapped to their
// upper case.
func (ns *Namespace) ToUpper(s interface{}) (string, error) {
ss, err := cast.ToStringE(s)
if err != nil {
return "", err
}
return _strings.ToUpper(ss), nil
}
// Trim returns a string with all leading and trailing characters defined
// contained in cutset removed.
func (ns *Namespace) Trim(s, cutset interface{}) (string, error) {
ss, err := cast.ToStringE(s)
if err != nil {
return "", err
}
sc, err := cast.ToStringE(cutset)
if err != nil {
return "", err
}
return _strings.Trim(ss, sc), nil
}
// TrimPrefix returns s without the provided leading prefix string. If s doesn't
// start with prefix, s is returned unchanged.
func (ns *Namespace) TrimPrefix(s, prefix interface{}) (string, error) {
ss, err := cast.ToStringE(s)
if err != nil {
return "", err
}
sx, err := cast.ToStringE(prefix)
if err != nil {
return "", err
}
return _strings.TrimPrefix(ss, sx), nil
}
// TrimSuffix returns s without the provided trailing suffix string. If s
// doesn't end with suffix, s is returned unchanged.
func (ns *Namespace) TrimSuffix(s, suffix interface{}) (string, error) {
ss, err := cast.ToStringE(s)
if err != nil {
return "", err
}
sx, err := cast.ToStringE(suffix)
if err != nil {
return "", err
}
return _strings.TrimSuffix(ss, sx), nil
}

639
tpl/strings/strings_test.go Normal file
View File

@@ -0,0 +1,639 @@
// Copyright 2017 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 strings
import (
"fmt"
"html/template"
"testing"
"github.com/spf13/hugo/deps"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var ns = New(&deps.Deps{})
type tstNoStringer struct{}
func TestNamespace(t *testing.T) {
t.Parallel()
assert.Equal(t, ns, ns.Namespace(), "object pointers should match")
}
func TestChomp(t *testing.T) {
t.Parallel()
for i, test := range []struct {
s interface{}
expect interface{}
}{
{"\n a\n", template.HTML("\n a")},
{"\n a\n\n", template.HTML("\n a")},
{"\n a\r\n", template.HTML("\n a")},
{"\n a\n\r\n", template.HTML("\n a")},
{"\n a\r\r", template.HTML("\n a")},
{"\n a\r", template.HTML("\n a")},
// errors
{tstNoStringer{}, false},
} {
errMsg := fmt.Sprintf("[%d] %v", i, test)
result, err := ns.Chomp(test.s)
if b, ok := test.expect.(bool); ok && !b {
require.Error(t, err, errMsg)
continue
}
require.NoError(t, err, errMsg)
assert.Equal(t, test.expect, result, errMsg)
}
}
func TestContains(t *testing.T) {
t.Parallel()
for i, test := range []struct {
s interface{}
substr interface{}
expect bool
isErr bool
}{
{"", "", true, false},
{"123", "23", true, false},
{"123", "234", false, false},
{"123", "", true, false},
{"", "a", false, false},
{123, "23", true, false},
{123, "234", false, false},
{123, "", true, false},
{template.HTML("123"), []byte("23"), true, false},
{template.HTML("123"), []byte("234"), false, false},
{template.HTML("123"), []byte(""), true, false},
// errors
{"", tstNoStringer{}, false, true},
{tstNoStringer{}, "", false, true},
} {
errMsg := fmt.Sprintf("[%d] %v", i, test)
result, err := ns.Contains(test.s, test.substr)
if test.isErr {
require.Error(t, err, errMsg)
continue
}
require.NoError(t, err, errMsg)
assert.Equal(t, test.expect, result, errMsg)
}
}
func TestContainsAny(t *testing.T) {
t.Parallel()
for i, test := range []struct {
s interface{}
substr interface{}
expect bool
isErr bool
}{
{"", "", false, false},
{"", "1", false, false},
{"", "123", false, false},
{"1", "", false, false},
{"1", "1", true, false},
{"111", "1", true, false},
{"123", "789", false, false},
{"123", "729", true, false},
{"a☺b☻c☹d", "uvw☻xyz", true, false},
{1, "", false, false},
{1, "1", true, false},
{111, "1", true, false},
{123, "789", false, false},
{123, "729", true, false},
{[]byte("123"), template.HTML("789"), false, false},
{[]byte("123"), template.HTML("729"), true, false},
{[]byte("a☺b☻c☹d"), template.HTML("uvw☻xyz"), true, false},
// errors
{"", tstNoStringer{}, false, true},
{tstNoStringer{}, "", false, true},
} {
errMsg := fmt.Sprintf("[%d] %v", i, test)
result, err := ns.ContainsAny(test.s, test.substr)
if test.isErr {
require.Error(t, err, errMsg)
continue
}
require.NoError(t, err, errMsg)
assert.Equal(t, test.expect, result, errMsg)
}
}
func TestCountRunes(t *testing.T) {
t.Parallel()
for i, test := range []struct {
s interface{}
expect interface{}
}{
{"foo bar", 6},
{"旁边", 2},
{`<div class="test">旁边</div>`, 2},
// errors
{tstNoStringer{}, false},
} {
errMsg := fmt.Sprintf("[%d] %v", i, test.s)
result, err := ns.CountRunes(test.s)
if b, ok := test.expect.(bool); ok && !b {
require.Error(t, err, errMsg)
continue
}
require.NoError(t, err, errMsg)
assert.Equal(t, test.expect, result, errMsg)
}
}
func TestCountWords(t *testing.T) {
t.Parallel()
for i, test := range []struct {
s interface{}
expect interface{}
}{
{"Do Be Do Be Do", 5},
{"旁边", 2},
{`<div class="test">旁边</div>`, 2},
// errors
{tstNoStringer{}, false},
} {
errMsg := fmt.Sprintf("[%d] %v", i, test.s)
result, err := ns.CountWords(test.s)
if b, ok := test.expect.(bool); ok && !b {
require.Error(t, err, errMsg)
continue
}
require.NoError(t, err, errMsg)
assert.Equal(t, test.expect, result, errMsg)
}
}
func TestHasPrefix(t *testing.T) {
t.Parallel()
for i, test := range []struct {
s interface{}
prefix interface{}
expect interface{}
isErr bool
}{
{"abcd", "ab", true, false},
{"abcd", "cd", false, false},
{template.HTML("abcd"), "ab", true, false},
{template.HTML("abcd"), "cd", false, false},
{template.HTML("1234"), 12, true, false},
{template.HTML("1234"), 34, false, false},
{[]byte("abcd"), "ab", true, false},
// errors
{"", tstNoStringer{}, false, true},
{tstNoStringer{}, "", false, true},
} {
errMsg := fmt.Sprintf("[%d] %v", i, test)
result, err := ns.HasPrefix(test.s, test.prefix)
if test.isErr {
require.Error(t, err, errMsg)
continue
}
require.NoError(t, err, errMsg)
assert.Equal(t, test.expect, result, errMsg)
}
}
func TestHasSuffix(t *testing.T) {
t.Parallel()
for i, test := range []struct {
s interface{}
suffix interface{}
expect interface{}
isErr bool
}{
{"abcd", "cd", true, false},
{"abcd", "ab", false, false},
{template.HTML("abcd"), "cd", true, false},
{template.HTML("abcd"), "ab", false, false},
{template.HTML("1234"), 34, true, false},
{template.HTML("1234"), 12, false, false},
{[]byte("abcd"), "cd", true, false},
// errors
{"", tstNoStringer{}, false, true},
{tstNoStringer{}, "", false, true},
} {
errMsg := fmt.Sprintf("[%d] %v", i, test)
result, err := ns.HasSuffix(test.s, test.suffix)
if test.isErr {
require.Error(t, err, errMsg)
continue
}
require.NoError(t, err, errMsg)
assert.Equal(t, test.expect, result, errMsg)
}
}
func TestReplace(t *testing.T) {
t.Parallel()
for i, test := range []struct {
s interface{}
old interface{}
new interface{}
expect interface{}
}{
{"aab", "a", "b", "bbb"},
{"11a11", 1, 2, "22a22"},
{12345, 1, 2, "22345"},
// errors
{tstNoStringer{}, "a", "b", false},
{"a", tstNoStringer{}, "b", false},
{"a", "b", tstNoStringer{}, false},
} {
errMsg := fmt.Sprintf("[%d] %v", i, test)
result, err := ns.Replace(test.s, test.old, test.new)
if b, ok := test.expect.(bool); ok && !b {
require.Error(t, err, errMsg)
continue
}
require.NoError(t, err, errMsg)
assert.Equal(t, test.expect, result, errMsg)
}
}
func TestSliceString(t *testing.T) {
t.Parallel()
var err error
for i, test := range []struct {
v1 interface{}
v2 interface{}
v3 interface{}
expect interface{}
}{
{"abc", 1, 2, "b"},
{"abc", 1, 3, "bc"},
{"abcdef", 1, int8(3), "bc"},
{"abcdef", 1, int16(3), "bc"},
{"abcdef", 1, int32(3), "bc"},
{"abcdef", 1, int64(3), "bc"},
{"abc", 0, 1, "a"},
{"abcdef", nil, nil, "abcdef"},
{"abcdef", 0, 6, "abcdef"},
{"abcdef", 0, 2, "ab"},
{"abcdef", 2, nil, "cdef"},
{"abcdef", int8(2), nil, "cdef"},
{"abcdef", int16(2), nil, "cdef"},
{"abcdef", int32(2), nil, "cdef"},
{"abcdef", int64(2), nil, "cdef"},
{123, 1, 3, "23"},
{"abcdef", 6, nil, false},
{"abcdef", 4, 7, false},
{"abcdef", -1, nil, false},
{"abcdef", -1, 7, false},
{"abcdef", 1, -1, false},
{tstNoStringer{}, 0, 1, false},
{"ĀĀĀ", 0, 1, "Ā"}, // issue #1333
{"a", t, nil, false},
{"a", 1, t, false},
} {
errMsg := fmt.Sprintf("[%d] %v", i, test)
var result string
if test.v2 == nil {
result, err = ns.SliceString(test.v1)
} else if test.v3 == nil {
result, err = ns.SliceString(test.v1, test.v2)
} else {
result, err = ns.SliceString(test.v1, test.v2, test.v3)
}
if b, ok := test.expect.(bool); ok && !b {
require.Error(t, err, errMsg)
continue
}
require.NoError(t, err, errMsg)
assert.Equal(t, test.expect, result, errMsg)
}
// Too many arguments
_, err = ns.SliceString("a", 1, 2, 3)
if err == nil {
t.Errorf("Should have errored")
}
}
func TestSplit(t *testing.T) {
t.Parallel()
for i, test := range []struct {
v1 interface{}
v2 string
expect interface{}
}{
{"a, b", ", ", []string{"a", "b"}},
{"a & b & c", " & ", []string{"a", "b", "c"}},
{"http://example.com", "http://", []string{"", "example.com"}},
{123, "2", []string{"1", "3"}},
{tstNoStringer{}, ",", false},
} {
errMsg := fmt.Sprintf("[%d] %v", i, test)
result, err := ns.Split(test.v1, test.v2)
if b, ok := test.expect.(bool); ok && !b {
require.Error(t, err, errMsg)
continue
}
require.NoError(t, err, errMsg)
assert.Equal(t, test.expect, result, errMsg)
}
}
func TestSubstr(t *testing.T) {
t.Parallel()
var err error
var n int
for i, test := range []struct {
v1 interface{}
v2 interface{}
v3 interface{}
expect interface{}
}{
{"abc", 1, 2, "bc"},
{"abc", 0, 1, "a"},
{"abcdef", -1, 2, "ef"},
{"abcdef", -3, 3, "bcd"},
{"abcdef", 0, -1, "abcde"},
{"abcdef", 2, -1, "cde"},
{"abcdef", 4, -4, false},
{"abcdef", 7, 1, false},
{"abcdef", 1, 100, "bcdef"},
{"abcdef", -100, 3, "abc"},
{"abcdef", -3, -1, "de"},
{"abcdef", 2, nil, "cdef"},
{"abcdef", int8(2), nil, "cdef"},
{"abcdef", int16(2), nil, "cdef"},
{"abcdef", int32(2), nil, "cdef"},
{"abcdef", int64(2), nil, "cdef"},
{"abcdef", 2, int8(3), "cde"},
{"abcdef", 2, int16(3), "cde"},
{"abcdef", 2, int32(3), "cde"},
{"abcdef", 2, int64(3), "cde"},
{123, 1, 3, "23"},
{1.2e3, 0, 4, "1200"},
{tstNoStringer{}, 0, 1, false},
{"abcdef", 2.0, nil, "cdef"},
{"abcdef", 2.0, 2, "cd"},
{"abcdef", 2, 2.0, "cd"},
{"ĀĀĀ", 1, 2, "ĀĀ"}, // # issue 1333
{"abcdef", "doo", nil, false},
{"abcdef", "doo", "doo", false},
{"abcdef", 1, "doo", false},
} {
errMsg := fmt.Sprintf("[%d] %v", i, test)
var result string
n = i
if test.v3 == nil {
result, err = ns.Substr(test.v1, test.v2)
} else {
result, err = ns.Substr(test.v1, test.v2, test.v3)
}
if b, ok := test.expect.(bool); ok && !b {
require.Error(t, err, errMsg)
continue
}
require.NoError(t, err, errMsg)
assert.Equal(t, test.expect, result, errMsg)
}
n++
_, err = ns.Substr("abcdef")
if err == nil {
t.Errorf("[%d] Substr didn't return an expected error", n)
}
n++
_, err = ns.Substr("abcdef", 1, 2, 3)
if err == nil {
t.Errorf("[%d] Substr didn't return an expected error", n)
}
}
func TestTitle(t *testing.T) {
t.Parallel()
for i, test := range []struct {
s interface{}
expect interface{}
}{
{"test", "Test"},
{template.HTML("hypertext"), "Hypertext"},
{[]byte("bytes"), "Bytes"},
// errors
{tstNoStringer{}, false},
} {
errMsg := fmt.Sprintf("[%d] %v", i, test)
result, err := ns.Title(test.s)
if b, ok := test.expect.(bool); ok && !b {
require.Error(t, err, errMsg)
continue
}
require.NoError(t, err, errMsg)
assert.Equal(t, test.expect, result, errMsg)
}
}
func TestToLower(t *testing.T) {
t.Parallel()
for i, test := range []struct {
s interface{}
expect interface{}
}{
{"TEST", "test"},
{template.HTML("LoWeR"), "lower"},
{[]byte("BYTES"), "bytes"},
// errors
{tstNoStringer{}, false},
} {
errMsg := fmt.Sprintf("[%d] %v", i, test)
result, err := ns.ToLower(test.s)
if b, ok := test.expect.(bool); ok && !b {
require.Error(t, err, errMsg)
continue
}
require.NoError(t, err, errMsg)
assert.Equal(t, test.expect, result, errMsg)
}
}
func TestToUpper(t *testing.T) {
t.Parallel()
for i, test := range []struct {
s interface{}
expect interface{}
}{
{"test", "TEST"},
{template.HTML("UpPeR"), "UPPER"},
{[]byte("bytes"), "BYTES"},
// errors
{tstNoStringer{}, false},
} {
errMsg := fmt.Sprintf("[%d] %v", i, test)
result, err := ns.ToUpper(test.s)
if b, ok := test.expect.(bool); ok && !b {
require.Error(t, err, errMsg)
continue
}
require.NoError(t, err, errMsg)
assert.Equal(t, test.expect, result, errMsg)
}
}
func TestTrim(t *testing.T) {
t.Parallel()
for i, test := range []struct {
s interface{}
cutset interface{}
expect interface{}
}{
{"abba", "a", "bb"},
{"abba", "ab", ""},
{"<tag>", "<>", "tag"},
{`"quote"`, `"`, "quote"},
{1221, "1", "22"},
{1221, "12", ""},
{template.HTML("<tag>"), "<>", "tag"},
{[]byte("<tag>"), "<>", "tag"},
// errors
{"", tstNoStringer{}, false},
{tstNoStringer{}, "", false},
} {
errMsg := fmt.Sprintf("[%d] %v", i, test)
result, err := ns.Trim(test.s, test.cutset)
if b, ok := test.expect.(bool); ok && !b {
require.Error(t, err, errMsg)
continue
}
require.NoError(t, err, errMsg)
assert.Equal(t, test.expect, result, errMsg)
}
}
func TestTrimPrefix(t *testing.T) {
t.Parallel()
for i, test := range []struct {
s interface{}
prefix interface{}
expect interface{}
}{
{"aabbaa", "a", "abbaa"},
{"aabb", "b", "aabb"},
{1234, "12", "34"},
{1234, "34", "1234"},
// errors
{"", tstNoStringer{}, false},
{tstNoStringer{}, "", false},
} {
errMsg := fmt.Sprintf("[%d] %v", i, test)
result, err := ns.TrimPrefix(test.s, test.prefix)
if b, ok := test.expect.(bool); ok && !b {
require.Error(t, err, errMsg)
continue
}
require.NoError(t, err, errMsg)
assert.Equal(t, test.expect, result, errMsg)
}
}
func TestTrimSuffix(t *testing.T) {
t.Parallel()
for i, test := range []struct {
s interface{}
suffix interface{}
expect interface{}
}{
{"aabbaa", "a", "aabba"},
{"aabb", "b", "aab"},
{1234, "12", "1234"},
{1234, "34", "12"},
// errors
{"", tstNoStringer{}, false},
{tstNoStringer{}, "", false},
} {
errMsg := fmt.Sprintf("[%d] %v", i, test)
result, err := ns.TrimSuffix(test.s, test.suffix)
if b, ok := test.expect.(bool); ok && !b {
require.Error(t, err, errMsg)
continue
}
require.NoError(t, err, errMsg)
assert.Equal(t, test.expect, result, errMsg)
}
}

156
tpl/strings/truncate.go Normal file
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 strings
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 (ns *Namespace) 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,84 @@
// 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 strings
import (
"html/template"
"reflect"
"strings"
"testing"
)
func TestTruncate(t *testing.T) {
t.Parallel()
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 = ns.Truncate(c.v1)
} else if c.v3 == nil {
result, err = ns.Truncate(c.v1, c.v2)
} else {
result, err = ns.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 = ns.Truncate(10, " ...", "I am a test sentence", "wrong")
if err == nil {
t.Errorf("Should have errored")
}
}