Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/gohugoio/hugo.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'tpl/strings')
-rw-r--r--tpl/strings/init.go229
-rw-r--r--tpl/strings/regexp.go125
-rw-r--r--tpl/strings/regexp_test.go93
-rw-r--r--tpl/strings/strings.go505
-rw-r--r--tpl/strings/strings_test.go787
-rw-r--r--tpl/strings/truncate.go157
-rw-r--r--tpl/strings/truncate_test.go83
7 files changed, 1979 insertions, 0 deletions
diff --git a/tpl/strings/init.go b/tpl/strings/init.go
new file mode 100644
index 000000000..a11246e1c
--- /dev/null
+++ b/tpl/strings/init.go
@@ -0,0 +1,229 @@
+// 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 (
+ "github.com/gohugoio/hugo/deps"
+ "github.com/gohugoio/hugo/tpl/internal"
+)
+
+const name = "strings"
+
+func init() {
+ f := func(d *deps.Deps) *internal.TemplateFuncsNamespace {
+ ctx := New(d)
+
+ ns := &internal.TemplateFuncsNamespace{
+ Name: name,
+ Context: func(args ...any) (any, error) { return ctx, nil },
+ }
+
+ ns.AddMethodMapping(ctx.Chomp,
+ []string{"chomp"},
+ [][2]string{
+ {`{{chomp "<p>Blockhead</p>\n" | safeHTML }}`, `<p>Blockhead</p>`},
+ },
+ )
+
+ ns.AddMethodMapping(ctx.CountRunes,
+ []string{"countrunes"},
+ [][2]string{},
+ )
+
+ ns.AddMethodMapping(ctx.RuneCount,
+ nil,
+ [][2]string{},
+ )
+
+ ns.AddMethodMapping(ctx.CountWords,
+ []string{"countwords"},
+ [][2]string{},
+ )
+
+ ns.AddMethodMapping(ctx.Count,
+ nil,
+ [][2]string{
+ {`{{"aabab" | strings.Count "a" }}`, `3`},
+ },
+ )
+
+ ns.AddMethodMapping(ctx.Contains,
+ nil,
+ [][2]string{
+ {`{{ strings.Contains "abc" "b" }}`, `true`},
+ {`{{ strings.Contains "abc" "d" }}`, `false`},
+ },
+ )
+
+ ns.AddMethodMapping(ctx.ContainsAny,
+ nil,
+ [][2]string{
+ {`{{ strings.ContainsAny "abc" "bcd" }}`, `true`},
+ {`{{ strings.ContainsAny "abc" "def" }}`, `false`},
+ },
+ )
+
+ ns.AddMethodMapping(ctx.FindRE,
+ []string{"findRE"},
+ [][2]string{
+ {
+ `{{ findRE "[G|g]o" "Hugo is a static side generator written in Go." "1" }}`,
+ `[go]`,
+ },
+ },
+ )
+
+ ns.AddMethodMapping(ctx.HasPrefix,
+ []string{"hasPrefix"},
+ [][2]string{
+ {`{{ hasPrefix "Hugo" "Hu" }}`, `true`},
+ {`{{ hasPrefix "Hugo" "Fu" }}`, `false`},
+ },
+ )
+
+ ns.AddMethodMapping(ctx.ToLower,
+ []string{"lower"},
+ [][2]string{
+ {`{{lower "BatMan"}}`, `batman`},
+ },
+ )
+
+ ns.AddMethodMapping(ctx.Replace,
+ []string{"replace"},
+ [][2]string{
+ {
+ `{{ replace "Batman and Robin" "Robin" "Catwoman" }}`,
+ `Batman and Catwoman`,
+ },
+ {
+ `{{ replace "aabbaabb" "a" "z" 2 }}`,
+ `zzbbaabb`,
+ },
+ },
+ )
+
+ ns.AddMethodMapping(ctx.ReplaceRE,
+ []string{"replaceRE"},
+ [][2]string{
+ {
+ `{{ replaceRE "a+b" "X" "aabbaabbab" }}`,
+ `XbXbX`,
+ },
+ {
+ `{{ replaceRE "a+b" "X" "aabbaabbab" 1 }}`,
+ `Xbaabbab`,
+ },
+ },
+ )
+
+ ns.AddMethodMapping(ctx.SliceString,
+ []string{"slicestr"},
+ [][2]string{
+ {`{{slicestr "BatMan" 0 3}}`, `Bat`},
+ {`{{slicestr "BatMan" 3}}`, `Man`},
+ },
+ )
+
+ ns.AddMethodMapping(ctx.Split,
+ []string{"split"},
+ [][2]string{},
+ )
+
+ ns.AddMethodMapping(ctx.Substr,
+ []string{"substr"},
+ [][2]string{
+ {`{{substr "BatMan" 0 -3}}`, `Bat`},
+ {`{{substr "BatMan" 3 3}}`, `Man`},
+ },
+ )
+
+ ns.AddMethodMapping(ctx.Trim,
+ []string{"trim"},
+ [][2]string{
+ {`{{ trim "++Batman--" "+-" }}`, `Batman`},
+ },
+ )
+
+ ns.AddMethodMapping(ctx.TrimLeft,
+ nil,
+ [][2]string{
+ {`{{ "aabbaa" | strings.TrimLeft "a" }}`, `bbaa`},
+ },
+ )
+
+ ns.AddMethodMapping(ctx.TrimPrefix,
+ nil,
+ [][2]string{
+ {`{{ "aabbaa" | strings.TrimPrefix "a" }}`, `abbaa`},
+ {`{{ "aabbaa" | strings.TrimPrefix "aa" }}`, `bbaa`},
+ },
+ )
+
+ ns.AddMethodMapping(ctx.TrimRight,
+ nil,
+ [][2]string{
+ {`{{ "aabbaa" | strings.TrimRight "a" }}`, `aabb`},
+ },
+ )
+
+ ns.AddMethodMapping(ctx.TrimSuffix,
+ nil,
+ [][2]string{
+ {`{{ "aabbaa" | strings.TrimSuffix "a" }}`, `aabba`},
+ {`{{ "aabbaa" | strings.TrimSuffix "aa" }}`, `aabb`},
+ },
+ )
+
+ ns.AddMethodMapping(ctx.Title,
+ []string{"title"},
+ [][2]string{
+ {`{{title "Bat man"}}`, `Bat Man`},
+ {`{{title "somewhere over the rainbow"}}`, `Somewhere Over the Rainbow`},
+ },
+ )
+
+ ns.AddMethodMapping(ctx.FirstUpper,
+ nil,
+ [][2]string{
+ {`{{ "hugo rocks!" | strings.FirstUpper }}`, `Hugo rocks!`},
+ },
+ )
+
+ ns.AddMethodMapping(ctx.Truncate,
+ []string{"truncate"},
+ [][2]string{
+ {`{{ "this is a very long text" | truncate 10 " ..." }}`, `this is a ...`},
+ {`{{ "With [Markdown](/markdown) inside." | markdownify | truncate 14 }}`, `With <a href="/markdown">Markdown …</a>`},
+ },
+ )
+
+ ns.AddMethodMapping(ctx.Repeat,
+ nil,
+ [][2]string{
+ {`{{ "yo" | strings.Repeat 4 }}`, `yoyoyoyo`},
+ },
+ )
+
+ ns.AddMethodMapping(ctx.ToUpper,
+ []string{"upper"},
+ [][2]string{
+ {`{{upper "BatMan"}}`, `BATMAN`},
+ },
+ )
+
+ return ns
+ }
+
+ internal.AddTemplateFuncsNamespace(f)
+}
diff --git a/tpl/strings/regexp.go b/tpl/strings/regexp.go
new file mode 100644
index 000000000..5b6a812d4
--- /dev/null
+++ b/tpl/strings/regexp.go
@@ -0,0 +1,125 @@
+// 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 any, limit ...any) ([]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. The number of replacements
+// can be limited with an optional fourth parameter.
+func (ns *Namespace) ReplaceRE(pattern, repl, s any, n ...any) (_ 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
+ }
+
+ nn := -1
+ if len(n) > 0 {
+ nn, err = cast.ToIntE(n[0])
+ if err != nil {
+ return
+ }
+ }
+
+ re, err := reCache.Get(sp)
+ if err != nil {
+ return "", err
+ }
+
+ return re.ReplaceAllStringFunc(ss, func(str string) string {
+ if nn == 0 {
+ return str
+ }
+
+ nn -= 1
+ return re.ReplaceAllString(str, 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)}
diff --git a/tpl/strings/regexp_test.go b/tpl/strings/regexp_test.go
new file mode 100644
index 000000000..9ac098c17
--- /dev/null
+++ b/tpl/strings/regexp_test.go
@@ -0,0 +1,93 @@
+// 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 (
+ "testing"
+
+ qt "github.com/frankban/quicktest"
+)
+
+func TestFindRE(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ expr string
+ content any
+ limit any
+ expect any
+ }{
+ {"[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},
+ } {
+ result, err := ns.FindRE(test.expr, test.content, test.limit)
+
+ if b, ok := test.expect.(bool); ok && !b {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Check(result, qt.DeepEquals, test.expect)
+ }
+}
+
+func TestReplaceRE(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ pattern any
+ repl any
+ s any
+ n []any
+ expect any
+ }{
+ {"^https?://([^/]+).*", "$1", "http://gohugo.io/docs", nil, "gohugo.io"},
+ {"^https?://([^/]+).*", "$2", "http://gohugo.io/docs", nil, ""},
+ {"(ab)", "AB", "aabbaab", nil, "aABbaAB"},
+ {"(ab)", "AB", "aabbaab", []any{1}, "aABbaab"},
+ // errors
+ {"(ab", "AB", "aabb", nil, false}, // invalid re
+ {tstNoStringer{}, "$2", "http://gohugo.io/docs", nil, false},
+ {"^https?://([^/]+).*", tstNoStringer{}, "http://gohugo.io/docs", nil, false},
+ {"^https?://([^/]+).*", "$2", tstNoStringer{}, nil, false},
+ } {
+
+ var (
+ result string
+ err error
+ )
+ if len(test.n) > 0 {
+ result, err = ns.ReplaceRE(test.pattern, test.repl, test.s, test.n...)
+ } else {
+ result, err = ns.ReplaceRE(test.pattern, test.repl, test.s)
+ }
+
+ if b, ok := test.expect.(bool); ok && !b {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Check(result, qt.Equals, test.expect)
+ }
+}
diff --git a/tpl/strings/strings.go b/tpl/strings/strings.go
new file mode 100644
index 000000000..a49451483
--- /dev/null
+++ b/tpl/strings/strings.go
@@ -0,0 +1,505 @@
+// 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 provides template functions for manipulating strings.
+package strings
+
+import (
+ "errors"
+ "fmt"
+ "html/template"
+ "regexp"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/gohugoio/hugo/common/text"
+ "github.com/gohugoio/hugo/deps"
+ "github.com/gohugoio/hugo/helpers"
+ "github.com/gohugoio/hugo/tpl"
+
+ "github.com/spf13/cast"
+)
+
+// New returns a new instance of the strings-namespaced template functions.
+func New(d *deps.Deps) *Namespace {
+ titleCaseStyle := d.Cfg.GetString("titleCaseStyle")
+ titleFunc := helpers.GetTitleFunc(titleCaseStyle)
+ return &Namespace{deps: d, titleFunc: titleFunc}
+}
+
+// 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 {
+ titleFunc func(s string) string
+ deps *deps.Deps
+}
+
+// CountRunes returns the number of runes in s, excluding whitespace.
+func (ns *Namespace) CountRunes(s any) (int, error) {
+ ss, err := cast.ToStringE(s)
+ if err != nil {
+ return 0, fmt.Errorf("Failed to convert content to string: %w", err)
+ }
+
+ counter := 0
+ for _, r := range tpl.StripHTML(ss) {
+ if !helpers.IsWhitespace(r) {
+ counter++
+ }
+ }
+
+ return counter, nil
+}
+
+// RuneCount returns the number of runes in s.
+func (ns *Namespace) RuneCount(s any) (int, error) {
+ ss, err := cast.ToStringE(s)
+ if err != nil {
+ return 0, fmt.Errorf("Failed to convert content to string: %w", err)
+ }
+ return utf8.RuneCountInString(ss), nil
+}
+
+// CountWords returns the approximate word count in s.
+func (ns *Namespace) CountWords(s any) (int, error) {
+ ss, err := cast.ToStringE(s)
+ if err != nil {
+ return 0, fmt.Errorf("Failed to convert content to string: %w", err)
+ }
+
+ isCJKLanguage, err := regexp.MatchString(`\p{Han}|\p{Hangul}|\p{Hiragana}|\p{Katakana}`, ss)
+ if err != nil {
+ return 0, fmt.Errorf("Failed to match regex pattern against string: %w", err)
+ }
+
+ if !isCJKLanguage {
+ return len(strings.Fields(tpl.StripHTML(ss))), nil
+ }
+
+ counter := 0
+ for _, word := range strings.Fields(tpl.StripHTML(ss)) {
+ runeCount := utf8.RuneCountInString(word)
+ if len(word) == runeCount {
+ counter++
+ } else {
+ counter += runeCount
+ }
+ }
+
+ return counter, nil
+}
+
+// Count counts the number of non-overlapping instances of substr in s.
+// If substr is an empty string, Count returns 1 + the number of Unicode code points in s.
+func (ns *Namespace) Count(substr, s any) (int, error) {
+ substrs, err := cast.ToStringE(substr)
+ if err != nil {
+ return 0, fmt.Errorf("Failed to convert substr to string: %w", err)
+ }
+ ss, err := cast.ToStringE(s)
+ if err != nil {
+ return 0, fmt.Errorf("Failed to convert s to string: %w", err)
+ }
+ return strings.Count(ss, substrs), nil
+}
+
+// Chomp returns a copy of s with all trailing newline characters removed.
+func (ns *Namespace) Chomp(s any) (any, error) {
+ ss, err := cast.ToStringE(s)
+ if err != nil {
+ return "", err
+ }
+
+ res := text.Chomp(ss)
+ switch s.(type) {
+ case template.HTML:
+ return template.HTML(res), nil
+ default:
+ return res, nil
+ }
+}
+
+// Contains reports whether substr is in s.
+func (ns *Namespace) Contains(s, substr any) (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 any) (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 any) (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 any) (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. The number of replacements can be limited with an optional fourth
+// parameter.
+func (ns *Namespace) Replace(s, old, new any, limit ...any) (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
+ }
+
+ if len(limit) == 0 {
+ return strings.ReplaceAll(ss, so, sn), nil
+ }
+
+ lim, err := cast.ToIntE(limit[0])
+ if err != nil {
+ return "", err
+ }
+
+ return strings.Replace(ss, so, sn, lim), 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 any, startEnd ...any) (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 any, 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 any, nums ...any) (string, error) {
+ s, err := cast.ToStringE(a)
+ if err != nil {
+ return "", err
+ }
+
+ asRunes := []rune(s)
+ rlen := len(asRunes)
+
+ var start, length int
+
+ switch len(nums) {
+ case 0:
+ return "", errors.New("too few arguments")
+ case 1:
+ if start, err = cast.ToIntE(nums[0]); err != nil {
+ return "", errors.New("start argument must be an integer")
+ }
+ length = rlen
+ case 2:
+ if start, err = cast.ToIntE(nums[0]); err != nil {
+ return "", errors.New("start argument must be an integer")
+ }
+ if length, err = cast.ToIntE(nums[1]); err != nil {
+ return "", errors.New("length argument must be an integer")
+ }
+ default:
+ return "", errors.New("too many arguments")
+ }
+
+ if rlen == 0 {
+ return "", nil
+ }
+
+ if start < 0 {
+ start += rlen
+ }
+
+ // start was originally negative beyond rlen
+ if start < 0 {
+ start = 0
+ }
+
+ if start > rlen-1 {
+ return "", nil
+ }
+
+ end := rlen
+
+ switch {
+ case length == 0:
+ return "", nil
+ case length < 0:
+ end += length
+ case length > 0:
+ end = start + length
+ }
+
+ if start >= end {
+ return "", nil
+ }
+
+ if end < 0 {
+ return "", nil
+ }
+
+ if end > rlen {
+ end = rlen
+ }
+
+ return string(asRunes[start:end]), 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 any) (string, error) {
+ ss, err := cast.ToStringE(s)
+ if err != nil {
+ return "", err
+ }
+
+ return ns.titleFunc(ss), nil
+}
+
+// FirstUpper converts s making the first character upper case.
+func (ns *Namespace) FirstUpper(s any) (string, error) {
+ ss, err := cast.ToStringE(s)
+ if err != nil {
+ return "", err
+ }
+
+ return helpers.FirstUpper(ss), nil
+}
+
+// ToLower returns a copy of the input s with all Unicode letters mapped to their
+// lower case.
+func (ns *Namespace) ToLower(s any) (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 any) (string, error) {
+ ss, err := cast.ToStringE(s)
+ if err != nil {
+ return "", err
+ }
+
+ return strings.ToUpper(ss), nil
+}
+
+// Trim returns converts the strings s removing all leading and trailing characters defined
+// contained.
+func (ns *Namespace) Trim(s, cutset any) (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
+}
+
+// TrimLeft returns a slice of the string s with all leading characters
+// contained in cutset removed.
+func (ns *Namespace) TrimLeft(cutset, s any) (string, error) {
+ ss, err := cast.ToStringE(s)
+ if err != nil {
+ return "", err
+ }
+
+ sc, err := cast.ToStringE(cutset)
+ if err != nil {
+ return "", err
+ }
+
+ return strings.TrimLeft(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(prefix, s any) (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
+}
+
+// TrimRight returns a slice of the string s with all trailing characters
+// contained in cutset removed.
+func (ns *Namespace) TrimRight(cutset, s any) (string, error) {
+ ss, err := cast.ToStringE(s)
+ if err != nil {
+ return "", err
+ }
+
+ sc, err := cast.ToStringE(cutset)
+ if err != nil {
+ return "", err
+ }
+
+ return strings.TrimRight(ss, sc), 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(suffix, s any) (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
+}
+
+// Repeat returns a new string consisting of n copies of the string s.
+func (ns *Namespace) Repeat(n, s any) (string, error) {
+ ss, err := cast.ToStringE(s)
+ if err != nil {
+ return "", err
+ }
+
+ sn, err := cast.ToIntE(n)
+ if err != nil {
+ return "", err
+ }
+
+ if sn < 0 {
+ return "", errors.New("strings: negative Repeat count")
+ }
+
+ return strings.Repeat(ss, sn), nil
+}
diff --git a/tpl/strings/strings_test.go b/tpl/strings/strings_test.go
new file mode 100644
index 000000000..7e3960934
--- /dev/null
+++ b/tpl/strings/strings_test.go
@@ -0,0 +1,787 @@
+// 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 (
+ "html/template"
+ "testing"
+
+ "github.com/gohugoio/hugo/config"
+ "github.com/gohugoio/hugo/deps"
+
+ qt "github.com/frankban/quicktest"
+ "github.com/spf13/cast"
+)
+
+var ns = New(&deps.Deps{Cfg: config.New()})
+
+type tstNoStringer struct{}
+
+func TestChomp(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ s any
+ expect any
+ }{
+ {"\n a\n", "\n a"},
+ {"\n a\n\n", "\n a"},
+ {"\n a\r\n", "\n a"},
+ {"\n a\n\r\n", "\n a"},
+ {"\n a\r\r", "\n a"},
+ {"\n a\r", "\n a"},
+ // errors
+ {tstNoStringer{}, false},
+ } {
+
+ result, err := ns.Chomp(test.s)
+
+ if b, ok := test.expect.(bool); ok && !b {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.Equals, test.expect)
+
+ // repeat the check with template.HTML input
+ result, err = ns.Chomp(template.HTML(cast.ToString(test.s)))
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.Equals, template.HTML(cast.ToString(test.expect)))
+ }
+}
+
+func TestContains(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ s any
+ substr any
+ 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},
+ } {
+
+ result, err := ns.Contains(test.s, test.substr)
+
+ if test.isErr {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.Equals, test.expect)
+ }
+}
+
+func TestContainsAny(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ s any
+ substr any
+ 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},
+ } {
+
+ result, err := ns.ContainsAny(test.s, test.substr)
+
+ if test.isErr {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.Equals, test.expect)
+ }
+}
+
+func TestCountRunes(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ s any
+ expect any
+ }{
+ {"foo bar", 6},
+ {"旁边", 2},
+ {`<div class="test">旁边</div>`, 2},
+ // errors
+ {tstNoStringer{}, false},
+ } {
+
+ result, err := ns.CountRunes(test.s)
+
+ if b, ok := test.expect.(bool); ok && !b {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.Equals, test.expect)
+ }
+}
+
+func TestRuneCount(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ s any
+ expect any
+ }{
+ {"foo bar", 7},
+ {"旁边", 2},
+ {`<div class="test">旁边</div>`, 26},
+ // errors
+ {tstNoStringer{}, false},
+ } {
+
+ result, err := ns.RuneCount(test.s)
+
+ if b, ok := test.expect.(bool); ok && !b {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.Equals, test.expect)
+ }
+}
+
+func TestCountWords(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ s any
+ expect any
+ }{
+ {"Do Be Do Be Do", 5},
+ {"旁边", 2},
+ {`<div class="test">旁边</div>`, 2},
+ {"Here's to you...", 3},
+ {"Here’s to you...", 3},
+ {"Here’s to you…", 3},
+ // errors
+ {tstNoStringer{}, false},
+ } {
+
+ result, err := ns.CountWords(test.s)
+
+ if b, ok := test.expect.(bool); ok && !b {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.Equals, test.expect)
+ }
+}
+
+func TestHasPrefix(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ s any
+ prefix any
+ expect any
+ 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},
+ } {
+
+ result, err := ns.HasPrefix(test.s, test.prefix)
+
+ if test.isErr {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.Equals, test.expect)
+ }
+}
+
+func TestHasSuffix(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ s any
+ suffix any
+ expect any
+ 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},
+ } {
+
+ result, err := ns.HasSuffix(test.s, test.suffix)
+
+ if test.isErr {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.Equals, test.expect)
+ }
+}
+
+func TestReplace(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ s any
+ old any
+ new any
+ limit any
+ expect any
+ }{
+ {"aab", "a", "b", nil, "bbb"},
+ {"11a11", 1, 2, nil, "22a22"},
+ {12345, 1, 2, nil, "22345"},
+ {"aab", "a", "b", 1, "bab"},
+ {"11a11", 1, 2, 2, "22a11"},
+ // errors
+ {tstNoStringer{}, "a", "b", nil, false},
+ {"a", tstNoStringer{}, "b", nil, false},
+ {"a", "b", tstNoStringer{}, nil, false},
+ } {
+
+ var (
+ result string
+ err error
+ )
+
+ if test.limit != nil {
+ result, err = ns.Replace(test.s, test.old, test.new, test.limit)
+ } else {
+ result, err = ns.Replace(test.s, test.old, test.new)
+ }
+
+ if b, ok := test.expect.(bool); ok && !b {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.Equals, test.expect)
+ }
+}
+
+func TestSliceString(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ var err error
+ for _, test := range []struct {
+ v1 any
+ v2 any
+ v3 any
+ expect any
+ }{
+ {"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},
+ } {
+
+ 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 {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.Equals, test.expect)
+ }
+
+ // 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()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ v1 any
+ v2 string
+ expect any
+ }{
+ {"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},
+ } {
+
+ result, err := ns.Split(test.v1, test.v2)
+
+ if b, ok := test.expect.(bool); ok && !b {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.DeepEquals, test.expect)
+ }
+}
+
+func TestSubstr(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ var err error
+ for _, test := range []struct {
+ v1 any
+ v2 any
+ v3 any
+ expect any
+ }{
+ {"abc", 1, 2, "bc"},
+ {"abc", 0, 1, "a"},
+ {"abcdef", 0, 0, ""},
+ {"abcdef", 1, 0, ""},
+ {"abcdef", -1, 0, ""},
+ {"abcdef", -1, 2, "f"},
+ {"abcdef", -3, 3, "def"},
+ {"abcdef", -1, nil, "f"},
+ {"abcdef", -2, nil, "ef"},
+ {"abcdef", -3, 1, "d"},
+ {"abcdef", 0, -1, "abcde"},
+ {"abcdef", 2, -1, "cde"},
+ {"abcdef", 4, -4, ""},
+ {"abcdef", 7, 1, ""},
+ {"abcdef", 6, nil, ""},
+ {"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},
+ {"", 0, nil, ""},
+ } {
+
+ var result string
+
+ 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 {
+ c.Check(err, qt.Not(qt.IsNil), qt.Commentf("%v", test))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil, qt.Commentf("%v", test))
+ c.Check(result, qt.Equals, test.expect, qt.Commentf("%v", test))
+ }
+
+ _, err = ns.Substr("abcdef")
+ c.Assert(err, qt.Not(qt.IsNil))
+
+ _, err = ns.Substr("abcdef", 1, 2, 3)
+ c.Assert(err, qt.Not(qt.IsNil))
+}
+
+func TestTitle(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ s any
+ expect any
+ }{
+ {"test", "Test"},
+ {template.HTML("hypertext"), "Hypertext"},
+ {[]byte("bytes"), "Bytes"},
+ // errors
+ {tstNoStringer{}, false},
+ } {
+
+ result, err := ns.Title(test.s)
+
+ if b, ok := test.expect.(bool); ok && !b {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.Equals, test.expect)
+ }
+}
+
+func TestToLower(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ s any
+ expect any
+ }{
+ {"TEST", "test"},
+ {template.HTML("LoWeR"), "lower"},
+ {[]byte("BYTES"), "bytes"},
+ // errors
+ {tstNoStringer{}, false},
+ } {
+
+ result, err := ns.ToLower(test.s)
+
+ if b, ok := test.expect.(bool); ok && !b {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.Equals, test.expect)
+ }
+}
+
+func TestToUpper(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ s any
+ expect any
+ }{
+ {"test", "TEST"},
+ {template.HTML("UpPeR"), "UPPER"},
+ {[]byte("bytes"), "BYTES"},
+ // errors
+ {tstNoStringer{}, false},
+ } {
+
+ result, err := ns.ToUpper(test.s)
+
+ if b, ok := test.expect.(bool); ok && !b {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.Equals, test.expect)
+ }
+}
+
+func TestTrim(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ s any
+ cutset any
+ expect any
+ }{
+ {"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},
+ } {
+
+ result, err := ns.Trim(test.s, test.cutset)
+
+ if b, ok := test.expect.(bool); ok && !b {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.Equals, test.expect)
+ }
+}
+
+func TestTrimLeft(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ s any
+ cutset any
+ expect any
+ }{
+ {"abba", "a", "bba"},
+ {"abba", "ab", ""},
+ {"<tag>", "<>", "tag>"},
+ {`"quote"`, `"`, `quote"`},
+ {1221, "1", "221"},
+ {1221, "12", ""},
+ {"007", "0", "7"},
+ {template.HTML("<tag>"), "<>", "tag>"},
+ {[]byte("<tag>"), "<>", "tag>"},
+ // errors
+ {"", tstNoStringer{}, false},
+ {tstNoStringer{}, "", false},
+ } {
+
+ result, err := ns.TrimLeft(test.cutset, test.s)
+
+ if b, ok := test.expect.(bool); ok && !b {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.Equals, test.expect)
+ }
+}
+
+func TestTrimPrefix(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ s any
+ prefix any
+ expect any
+ }{
+ {"aabbaa", "a", "abbaa"},
+ {"aabb", "b", "aabb"},
+ {1234, "12", "34"},
+ {1234, "34", "1234"},
+ // errors
+ {"", tstNoStringer{}, false},
+ {tstNoStringer{}, "", false},
+ } {
+
+ result, err := ns.TrimPrefix(test.prefix, test.s)
+
+ if b, ok := test.expect.(bool); ok && !b {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.Equals, test.expect)
+ }
+}
+
+func TestTrimRight(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ s any
+ cutset any
+ expect any
+ }{
+ {"abba", "a", "abb"},
+ {"abba", "ab", ""},
+ {"<tag>", "<>", "<tag"},
+ {`"quote"`, `"`, `"quote`},
+ {1221, "1", "122"},
+ {1221, "12", ""},
+ {"007", "0", "007"},
+ {template.HTML("<tag>"), "<>", "<tag"},
+ {[]byte("<tag>"), "<>", "<tag"},
+ // errors
+ {"", tstNoStringer{}, false},
+ {tstNoStringer{}, "", false},
+ } {
+
+ result, err := ns.TrimRight(test.cutset, test.s)
+
+ if b, ok := test.expect.(bool); ok && !b {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.Equals, test.expect)
+ }
+}
+
+func TestTrimSuffix(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ s any
+ suffix any
+ expect any
+ }{
+ {"aabbaa", "a", "aabba"},
+ {"aabb", "b", "aab"},
+ {1234, "12", "1234"},
+ {1234, "34", "12"},
+ // errors
+ {"", tstNoStringer{}, false},
+ {tstNoStringer{}, "", false},
+ } {
+
+ result, err := ns.TrimSuffix(test.suffix, test.s)
+
+ if b, ok := test.expect.(bool); ok && !b {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.Equals, test.expect)
+ }
+}
+
+func TestRepeat(t *testing.T) {
+ t.Parallel()
+ c := qt.New(t)
+
+ for _, test := range []struct {
+ s any
+ n any
+ expect any
+ }{
+ {"yo", "2", "yoyo"},
+ {"~", "16", "~~~~~~~~~~~~~~~~"},
+ {"<tag>", "0", ""},
+ {"yay", "1", "yay"},
+ {1221, "1", "1221"},
+ {1221, 2, "12211221"},
+ {template.HTML("<tag>"), "2", "<tag><tag>"},
+ {[]byte("<tag>"), 2, "<tag><tag>"},
+ // errors
+ {"", tstNoStringer{}, false},
+ {tstNoStringer{}, "", false},
+ {"ab", -1, false},
+ } {
+
+ result, err := ns.Repeat(test.n, test.s)
+
+ if b, ok := test.expect.(bool); ok && !b {
+ c.Assert(err, qt.Not(qt.IsNil))
+ continue
+ }
+
+ c.Assert(err, qt.IsNil)
+ c.Assert(result, qt.Equals, test.expect)
+ }
+}
diff --git a/tpl/strings/truncate.go b/tpl/strings/truncate.go
new file mode 100644
index 000000000..dd6267280
--- /dev/null
+++ b/tpl/strings/truncate.go
@@ -0,0 +1,157 @@
+// 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
+}
+
+// Truncate truncates a given string to the specified length.
+func (ns *Namespace) Truncate(a any, options ...any) (template.HTML, error) {
+ length, err := cast.ToIntE(a)
+ if err != nil {
+ return "", err
+ }
+ var textParam any
+ 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/strings/truncate_test.go b/tpl/strings/truncate_test.go
new file mode 100644
index 000000000..f7d5d132d
--- /dev/null
+++ b/tpl/strings/truncate_test.go
@@ -0,0 +1,83 @@
+// 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 any
+ v2 any
+ v3 any
+ want any
+ 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")
+ }
+}