truncate.go (3785B)
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 "unicode" 22 "unicode/utf8" 23 24 "github.com/spf13/cast" 25 ) 26 27 var ( 28 tagRE = regexp.MustCompile(`^<(/)?([^ ]+?)(?:(\s*/)| .*?)?>`) 29 htmlSinglets = map[string]bool{ 30 "br": true, "col": true, "link": true, 31 "base": true, "img": true, "param": true, 32 "area": true, "hr": true, "input": true, 33 } 34 ) 35 36 type htmlTag struct { 37 name string 38 pos int 39 openTag bool 40 } 41 42 // Truncate truncates a given string to the specified length. 43 func (ns *Namespace) Truncate(a any, options ...any) (template.HTML, error) { 44 length, err := cast.ToIntE(a) 45 if err != nil { 46 return "", err 47 } 48 var textParam any 49 var ellipsis string 50 51 switch len(options) { 52 case 0: 53 return "", errors.New("truncate requires a length and a string") 54 case 1: 55 textParam = options[0] 56 ellipsis = " …" 57 case 2: 58 textParam = options[1] 59 ellipsis, err = cast.ToStringE(options[0]) 60 if err != nil { 61 return "", errors.New("ellipsis must be a string") 62 } 63 if _, ok := options[0].(template.HTML); !ok { 64 ellipsis = html.EscapeString(ellipsis) 65 } 66 default: 67 return "", errors.New("too many arguments passed to truncate") 68 } 69 if err != nil { 70 return "", errors.New("text to truncate must be a string") 71 } 72 text, err := cast.ToStringE(textParam) 73 if err != nil { 74 return "", errors.New("text must be a string") 75 } 76 77 _, isHTML := textParam.(template.HTML) 78 79 if utf8.RuneCountInString(text) <= length { 80 if isHTML { 81 return template.HTML(text), nil 82 } 83 return template.HTML(html.EscapeString(text)), nil 84 } 85 86 tags := []htmlTag{} 87 var lastWordIndex, lastNonSpace, currentLen, endTextPos, nextTag int 88 89 for i, r := range text { 90 if i < nextTag { 91 continue 92 } 93 94 if isHTML { 95 // Make sure we keep tag of HTML tags 96 slice := text[i:] 97 m := tagRE.FindStringSubmatchIndex(slice) 98 if len(m) > 0 && m[0] == 0 { 99 nextTag = i + m[1] 100 tagname := slice[m[4]:m[5]] 101 lastWordIndex = lastNonSpace 102 _, singlet := htmlSinglets[tagname] 103 if !singlet && m[6] == -1 { 104 tags = append(tags, htmlTag{name: tagname, pos: i, openTag: m[2] == -1}) 105 } 106 107 continue 108 } 109 } 110 111 currentLen++ 112 if unicode.IsSpace(r) { 113 lastWordIndex = lastNonSpace 114 } else if unicode.In(r, unicode.Han, unicode.Hangul, unicode.Hiragana, unicode.Katakana) { 115 lastWordIndex = i 116 } else { 117 lastNonSpace = i + utf8.RuneLen(r) 118 } 119 120 if currentLen > length { 121 if lastWordIndex == 0 { 122 endTextPos = i 123 } else { 124 endTextPos = lastWordIndex 125 } 126 out := text[0:endTextPos] 127 if isHTML { 128 out += ellipsis 129 // Close out any open HTML tags 130 var currentTag *htmlTag 131 for i := len(tags) - 1; i >= 0; i-- { 132 tag := tags[i] 133 if tag.pos >= endTextPos || currentTag != nil { 134 if currentTag != nil && currentTag.name == tag.name { 135 currentTag = nil 136 } 137 continue 138 } 139 140 if tag.openTag { 141 out += ("</" + tag.name + ">") 142 } else { 143 currentTag = &tag 144 } 145 } 146 147 return template.HTML(out), nil 148 } 149 return template.HTML(html.EscapeString(out) + ellipsis), nil 150 } 151 } 152 153 if isHTML { 154 return template.HTML(text), nil 155 } 156 return template.HTML(html.EscapeString(text)), nil 157 }