github.com/linchen2chris/hugo@v0.0.0-20230307053224-cec209389705/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  }