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 }