content.go (8032B)
1 // Copyright 2019 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 helpers implements general utility functions that work with 15 // and on content. The helper functions defined here lay down the 16 // foundation of how Hugo works with files and filepaths, and perform 17 // string operations on content. 18 package helpers 19 20 import ( 21 "bytes" 22 "html/template" 23 "strings" 24 "unicode" 25 "unicode/utf8" 26 27 "github.com/gohugoio/hugo/common/hexec" 28 "github.com/gohugoio/hugo/common/loggers" 29 30 "github.com/spf13/afero" 31 32 "github.com/gohugoio/hugo/markup/converter" 33 "github.com/gohugoio/hugo/markup/converter/hooks" 34 35 "github.com/gohugoio/hugo/markup" 36 37 "github.com/gohugoio/hugo/config" 38 ) 39 40 var ( 41 openingPTag = []byte("<p>") 42 closingPTag = []byte("</p>") 43 paragraphIndicator = []byte("<p") 44 closingIndicator = []byte("</") 45 ) 46 47 // ContentSpec provides functionality to render markdown content. 48 type ContentSpec struct { 49 Converters markup.ConverterProvider 50 anchorNameSanitizer converter.AnchorNameSanitizer 51 getRenderer func(t hooks.RendererType, id any) any 52 53 // SummaryLength is the length of the summary that Hugo extracts from a content. 54 summaryLength int 55 56 BuildFuture bool 57 BuildExpired bool 58 BuildDrafts bool 59 60 Cfg config.Provider 61 } 62 63 // NewContentSpec returns a ContentSpec initialized 64 // with the appropriate fields from the given config.Provider. 65 func NewContentSpec(cfg config.Provider, logger loggers.Logger, contentFs afero.Fs, ex *hexec.Exec) (*ContentSpec, error) { 66 spec := &ContentSpec{ 67 summaryLength: cfg.GetInt("summaryLength"), 68 BuildFuture: cfg.GetBool("buildFuture"), 69 BuildExpired: cfg.GetBool("buildExpired"), 70 BuildDrafts: cfg.GetBool("buildDrafts"), 71 72 Cfg: cfg, 73 } 74 75 converterProvider, err := markup.NewConverterProvider(converter.ProviderConfig{ 76 Cfg: cfg, 77 ContentFs: contentFs, 78 Logger: logger, 79 Exec: ex, 80 }) 81 if err != nil { 82 return nil, err 83 } 84 85 spec.Converters = converterProvider 86 p := converterProvider.Get("markdown") 87 conv, err := p.New(converter.DocumentContext{}) 88 if err != nil { 89 return nil, err 90 } 91 if as, ok := conv.(converter.AnchorNameSanitizer); ok { 92 spec.anchorNameSanitizer = as 93 } else { 94 // Use Goldmark's sanitizer 95 p := converterProvider.Get("goldmark") 96 conv, err := p.New(converter.DocumentContext{}) 97 if err != nil { 98 return nil, err 99 } 100 spec.anchorNameSanitizer = conv.(converter.AnchorNameSanitizer) 101 } 102 103 return spec, nil 104 } 105 106 // stripEmptyNav strips out empty <nav> tags from content. 107 func stripEmptyNav(in []byte) []byte { 108 return bytes.Replace(in, []byte("<nav>\n</nav>\n\n"), []byte(``), -1) 109 } 110 111 // BytesToHTML converts bytes to type template.HTML. 112 func BytesToHTML(b []byte) template.HTML { 113 return template.HTML(string(b)) 114 } 115 116 // ExtractTOC extracts Table of Contents from content. 117 func ExtractTOC(content []byte) (newcontent []byte, toc []byte) { 118 if !bytes.Contains(content, []byte("<nav>")) { 119 return content, nil 120 } 121 origContent := make([]byte, len(content)) 122 copy(origContent, content) 123 first := []byte(`<nav> 124 <ul>`) 125 126 last := []byte(`</ul> 127 </nav>`) 128 129 replacement := []byte(`<nav id="TableOfContents"> 130 <ul>`) 131 132 startOfTOC := bytes.Index(content, first) 133 134 peekEnd := len(content) 135 if peekEnd > 70+startOfTOC { 136 peekEnd = 70 + startOfTOC 137 } 138 139 if startOfTOC < 0 { 140 return stripEmptyNav(content), toc 141 } 142 // Need to peek ahead to see if this nav element is actually the right one. 143 correctNav := bytes.Index(content[startOfTOC:peekEnd], []byte(`<li><a href="#`)) 144 if correctNav < 0 { // no match found 145 return content, toc 146 } 147 lengthOfTOC := bytes.Index(content[startOfTOC:], last) + len(last) 148 endOfTOC := startOfTOC + lengthOfTOC 149 150 newcontent = append(content[:startOfTOC], content[endOfTOC:]...) 151 toc = append(replacement, origContent[startOfTOC+len(first):endOfTOC]...) 152 return 153 } 154 155 func (c *ContentSpec) SanitizeAnchorName(s string) string { 156 return c.anchorNameSanitizer.SanitizeAnchorName(s) 157 } 158 159 func (c *ContentSpec) ResolveMarkup(in string) string { 160 in = strings.ToLower(in) 161 switch in { 162 case "md", "markdown", "mdown": 163 return "markdown" 164 case "html", "htm": 165 return "html" 166 default: 167 if conv := c.Converters.Get(in); conv != nil { 168 return conv.Name() 169 } 170 } 171 return "" 172 } 173 174 // TotalWords counts instance of one or more consecutive white space 175 // characters, as defined by unicode.IsSpace, in s. 176 // This is a cheaper way of word counting than the obvious len(strings.Fields(s)). 177 func TotalWords(s string) int { 178 n := 0 179 inWord := false 180 for _, r := range s { 181 wasInWord := inWord 182 inWord = !unicode.IsSpace(r) 183 if inWord && !wasInWord { 184 n++ 185 } 186 } 187 return n 188 } 189 190 // TruncateWordsByRune truncates words by runes. 191 func (c *ContentSpec) TruncateWordsByRune(in []string) (string, bool) { 192 words := make([]string, len(in)) 193 copy(words, in) 194 195 count := 0 196 for index, word := range words { 197 if count >= c.summaryLength { 198 return strings.Join(words[:index], " "), true 199 } 200 runeCount := utf8.RuneCountInString(word) 201 if len(word) == runeCount { 202 count++ 203 } else if count+runeCount < c.summaryLength { 204 count += runeCount 205 } else { 206 for ri := range word { 207 if count >= c.summaryLength { 208 truncatedWords := append(words[:index], word[:ri]) 209 return strings.Join(truncatedWords, " "), true 210 } 211 count++ 212 } 213 } 214 } 215 216 return strings.Join(words, " "), false 217 } 218 219 // TruncateWordsToWholeSentence takes content and truncates to whole sentence 220 // limited by max number of words. It also returns whether it is truncated. 221 func (c *ContentSpec) TruncateWordsToWholeSentence(s string) (string, bool) { 222 var ( 223 wordCount = 0 224 lastWordIndex = -1 225 ) 226 227 for i, r := range s { 228 if unicode.IsSpace(r) { 229 wordCount++ 230 lastWordIndex = i 231 232 if wordCount >= c.summaryLength { 233 break 234 } 235 236 } 237 } 238 239 if lastWordIndex == -1 { 240 return s, false 241 } 242 243 endIndex := -1 244 245 for j, r := range s[lastWordIndex:] { 246 if isEndOfSentence(r) { 247 endIndex = j + lastWordIndex + utf8.RuneLen(r) 248 break 249 } 250 } 251 252 if endIndex == -1 { 253 return s, false 254 } 255 256 return strings.TrimSpace(s[:endIndex]), endIndex < len(s) 257 } 258 259 // TrimShortHTML removes the <p>/</p> tags from HTML input in the situation 260 // where said tags are the only <p> tags in the input and enclose the content 261 // of the input (whitespace excluded). 262 func (c *ContentSpec) TrimShortHTML(input []byte) []byte { 263 firstOpeningP := bytes.Index(input, paragraphIndicator) 264 lastOpeningP := bytes.LastIndex(input, paragraphIndicator) 265 266 lastClosingP := bytes.LastIndex(input, closingPTag) 267 lastClosing := bytes.LastIndex(input, closingIndicator) 268 269 if firstOpeningP == lastOpeningP && lastClosingP == lastClosing { 270 input = bytes.TrimSpace(input) 271 input = bytes.TrimPrefix(input, openingPTag) 272 input = bytes.TrimSuffix(input, closingPTag) 273 input = bytes.TrimSpace(input) 274 } 275 return input 276 } 277 278 func isEndOfSentence(r rune) bool { 279 return r == '.' || r == '?' || r == '!' || r == '"' || r == '\n' 280 } 281 282 // Kept only for benchmark. 283 func (c *ContentSpec) truncateWordsToWholeSentenceOld(content string) (string, bool) { 284 words := strings.Fields(content) 285 286 if c.summaryLength >= len(words) { 287 return strings.Join(words, " "), false 288 } 289 290 for counter, word := range words[c.summaryLength:] { 291 if strings.HasSuffix(word, ".") || 292 strings.HasSuffix(word, "?") || 293 strings.HasSuffix(word, ".\"") || 294 strings.HasSuffix(word, "!") { 295 upper := c.summaryLength + counter + 1 296 return strings.Join(words[:upper], " "), (upper < len(words)) 297 } 298 } 299 300 return strings.Join(words[:c.summaryLength], " "), true 301 }