diff options
Diffstat (limited to 'tpl/strings')
-rw-r--r-- | tpl/strings/init.go | 229 | ||||
-rw-r--r-- | tpl/strings/regexp.go | 125 | ||||
-rw-r--r-- | tpl/strings/regexp_test.go | 93 | ||||
-rw-r--r-- | tpl/strings/strings.go | 505 | ||||
-rw-r--r-- | tpl/strings/strings_test.go | 787 | ||||
-rw-r--r-- | tpl/strings/truncate.go | 157 | ||||
-rw-r--r-- | tpl/strings/truncate_test.go | 83 |
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("<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 = 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") + } +} |