github.com/neohugo/neohugo@v0.123.8/tpl/strings/truncate.go (about) 1 // Copyright 2016 The Hugo Authors. All rights reserved. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // http://www.apache.org/licenses/LICENSE-2.0 7 // 8 // Unless required by applicable law or agreed to in writing, software 9 // distributed under the License is distributed on an "AS IS" BASIS, 10 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 // See the License for the specific language governing permissions and 12 // limitations under the License. 13 14 package strings 15 16 import ( 17 "errors" 18 "html" 19 "html/template" 20 "regexp" 21 "strings" 22 "unicode" 23 "unicode/utf8" 24 25 "github.com/spf13/cast" 26 ) 27 28 var ( 29 tagRE = regexp.MustCompile(`^<(/)?([^ ]+?)(?:(\s*/)| .*?)?>`) 30 htmlSinglets = map[string]bool{ 31 "br": true, "col": true, "link": true, 32 "base": true, "img": true, "param": true, 33 "area": true, "hr": true, "input": true, 34 } 35 ) 36 37 type htmlTag struct { 38 name string 39 pos int 40 openTag bool 41 } 42 43 // Truncate truncates the string in s to the specified length. 44 func (ns *Namespace) Truncate(s any, options ...any) (template.HTML, error) { 45 length, err := cast.ToIntE(s) 46 if err != nil { 47 return "", err 48 } 49 var textParam any 50 var ellipsis string 51 52 switch len(options) { 53 case 0: 54 return "", errors.New("truncate requires a length and a string") 55 case 1: 56 textParam = options[0] 57 ellipsis = " …" 58 case 2: 59 textParam = options[1] 60 ellipsis, err = cast.ToStringE(options[0]) 61 if err != nil { 62 return "", errors.New("ellipsis must be a string") 63 } 64 if _, ok := options[0].(template.HTML); !ok { 65 ellipsis = html.EscapeString(ellipsis) 66 } 67 default: 68 return "", errors.New("too many arguments passed to truncate") 69 } 70 if err != nil { 71 return "", errors.New("text to truncate must be a string") 72 } 73 text, err := cast.ToStringE(textParam) 74 if err != nil { 75 return "", errors.New("text must be a string") 76 } 77 78 _, isHTML := textParam.(template.HTML) 79 80 if utf8.RuneCountInString(text) <= length { 81 if isHTML { 82 return template.HTML(text), nil 83 } 84 return template.HTML(html.EscapeString(text)), nil 85 } 86 87 tags := []htmlTag{} 88 var lastWordIndex, lastNonSpace, currentLen, endTextPos, nextTag int 89 90 for i, r := range text { 91 if i < nextTag { 92 continue 93 } 94 95 if isHTML { 96 // Make sure we keep tagname of HTML tags 97 slice := text[i:] 98 m := tagRE.FindStringSubmatchIndex(slice) 99 if len(m) > 0 && m[0] == 0 { 100 nextTag = i + m[1] 101 tagname := strings.Fields(slice[m[4]:m[5]])[0] 102 lastWordIndex = lastNonSpace 103 _, singlet := htmlSinglets[tagname] 104 if !singlet && m[6] == -1 { 105 tags = append(tags, htmlTag{name: tagname, pos: i, openTag: m[2] == -1}) 106 } 107 108 continue 109 } 110 } 111 112 currentLen++ 113 if unicode.IsSpace(r) { 114 lastWordIndex = lastNonSpace 115 } else if unicode.In(r, unicode.Han, unicode.Hangul, unicode.Hiragana, unicode.Katakana) { 116 lastWordIndex = i 117 } else { 118 lastNonSpace = i + utf8.RuneLen(r) 119 } 120 121 if currentLen > length { 122 if lastWordIndex == 0 { 123 endTextPos = i 124 } else { 125 endTextPos = lastWordIndex 126 } 127 out := text[0:endTextPos] 128 if isHTML { 129 out += ellipsis 130 // Close out any open HTML tags 131 var currentTag *htmlTag 132 for i := len(tags) - 1; i >= 0; i-- { 133 tag := tags[i] 134 if tag.pos >= endTextPos || currentTag != nil { 135 if currentTag != nil && currentTag.name == tag.name { 136 currentTag = nil 137 } 138 continue 139 } 140 141 if tag.openTag { 142 out += ("</" + tag.name + ">") 143 } else { 144 currentTag = &tag 145 } 146 } 147 148 return template.HTML(out), nil 149 } 150 return template.HTML(html.EscapeString(out) + ellipsis), nil 151 } 152 } 153 154 if isHTML { 155 return template.HTML(text), nil 156 } 157 return template.HTML(html.EscapeString(text)), nil 158 }