github.com/graemephi/kahugo@v0.62.3-0.20211121071557-d78c0423784d/helpers/content.go (about) 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/loggers" 28 29 "github.com/spf13/afero" 30 31 "github.com/gohugoio/hugo/markup/converter" 32 33 "github.com/gohugoio/hugo/markup" 34 35 bp "github.com/gohugoio/hugo/bufferpool" 36 "github.com/gohugoio/hugo/config" 37 ) 38 39 // SummaryDivider denotes where content summarization should end. The default is "<!--more-->". 40 var SummaryDivider = []byte("<!--more-->") 41 42 var ( 43 openingPTag = []byte("<p>") 44 closingPTag = []byte("</p>") 45 paragraphIndicator = []byte("<p") 46 closingIndicator = []byte("</") 47 ) 48 49 // ContentSpec provides functionality to render markdown content. 50 type ContentSpec struct { 51 Converters markup.ConverterProvider 52 MardownConverter converter.Converter // Markdown converter with no document context 53 anchorNameSanitizer converter.AnchorNameSanitizer 54 55 // SummaryLength is the length of the summary that Hugo extracts from a content. 56 summaryLength int 57 58 BuildFuture bool 59 BuildExpired bool 60 BuildDrafts bool 61 62 Cfg config.Provider 63 } 64 65 // NewContentSpec returns a ContentSpec initialized 66 // with the appropriate fields from the given config.Provider. 67 func NewContentSpec(cfg config.Provider, logger loggers.Logger, contentFs afero.Fs) (*ContentSpec, error) { 68 spec := &ContentSpec{ 69 summaryLength: cfg.GetInt("summaryLength"), 70 BuildFuture: cfg.GetBool("buildFuture"), 71 BuildExpired: cfg.GetBool("buildExpired"), 72 BuildDrafts: cfg.GetBool("buildDrafts"), 73 74 Cfg: cfg, 75 } 76 77 converterProvider, err := markup.NewConverterProvider(converter.ProviderConfig{ 78 Cfg: cfg, 79 ContentFs: contentFs, 80 Logger: logger, 81 }) 82 if err != nil { 83 return nil, err 84 } 85 86 spec.Converters = converterProvider 87 p := converterProvider.Get("markdown") 88 conv, err := p.New(converter.DocumentContext{}) 89 if err != nil { 90 return nil, err 91 } 92 spec.MardownConverter = conv 93 if as, ok := conv.(converter.AnchorNameSanitizer); ok { 94 spec.anchorNameSanitizer = as 95 } else { 96 // Use Goldmark's sanitizer 97 p := converterProvider.Get("goldmark") 98 conv, err := p.New(converter.DocumentContext{}) 99 if err != nil { 100 return nil, err 101 } 102 spec.anchorNameSanitizer = conv.(converter.AnchorNameSanitizer) 103 } 104 105 return spec, nil 106 } 107 108 var stripHTMLReplacer = strings.NewReplacer("\n", " ", "</p>", "\n", "<br>", "\n", "<br />", "\n") 109 110 // StripHTML accepts a string, strips out all HTML tags and returns it. 111 func StripHTML(s string) string { 112 // Shortcut strings with no tags in them 113 if !strings.ContainsAny(s, "<>") { 114 return s 115 } 116 s = stripHTMLReplacer.Replace(s) 117 118 // Walk through the string removing all tags 119 b := bp.GetBuffer() 120 defer bp.PutBuffer(b) 121 var inTag, isSpace, wasSpace bool 122 for _, r := range s { 123 if !inTag { 124 isSpace = false 125 } 126 127 switch { 128 case r == '<': 129 inTag = true 130 case r == '>': 131 inTag = false 132 case unicode.IsSpace(r): 133 isSpace = true 134 fallthrough 135 default: 136 if !inTag && (!isSpace || (isSpace && !wasSpace)) { 137 b.WriteRune(r) 138 } 139 } 140 141 wasSpace = isSpace 142 143 } 144 return b.String() 145 } 146 147 // stripEmptyNav strips out empty <nav> tags from content. 148 func stripEmptyNav(in []byte) []byte { 149 return bytes.Replace(in, []byte("<nav>\n</nav>\n\n"), []byte(``), -1) 150 } 151 152 // BytesToHTML converts bytes to type template.HTML. 153 func BytesToHTML(b []byte) template.HTML { 154 return template.HTML(string(b)) 155 } 156 157 // ExtractTOC extracts Table of Contents from content. 158 func ExtractTOC(content []byte) (newcontent []byte, toc []byte) { 159 if !bytes.Contains(content, []byte("<nav>")) { 160 return content, nil 161 } 162 origContent := make([]byte, len(content)) 163 copy(origContent, content) 164 first := []byte(`<nav> 165 <ul>`) 166 167 last := []byte(`</ul> 168 </nav>`) 169 170 replacement := []byte(`<nav id="TableOfContents"> 171 <ul>`) 172 173 startOfTOC := bytes.Index(content, first) 174 175 peekEnd := len(content) 176 if peekEnd > 70+startOfTOC { 177 peekEnd = 70 + startOfTOC 178 } 179 180 if startOfTOC < 0 { 181 return stripEmptyNav(content), toc 182 } 183 // Need to peek ahead to see if this nav element is actually the right one. 184 correctNav := bytes.Index(content[startOfTOC:peekEnd], []byte(`<li><a href="#`)) 185 if correctNav < 0 { // no match found 186 return content, toc 187 } 188 lengthOfTOC := bytes.Index(content[startOfTOC:], last) + len(last) 189 endOfTOC := startOfTOC + lengthOfTOC 190 191 newcontent = append(content[:startOfTOC], content[endOfTOC:]...) 192 toc = append(replacement, origContent[startOfTOC+len(first):endOfTOC]...) 193 return 194 } 195 196 func (c *ContentSpec) RenderMarkdown(src []byte) ([]byte, error) { 197 b, err := c.MardownConverter.Convert(converter.RenderContext{Src: src}) 198 if err != nil { 199 return nil, err 200 } 201 return b.Bytes(), nil 202 } 203 204 func (c *ContentSpec) SanitizeAnchorName(s string) string { 205 return c.anchorNameSanitizer.SanitizeAnchorName(s) 206 } 207 208 func (c *ContentSpec) ResolveMarkup(in string) string { 209 in = strings.ToLower(in) 210 switch in { 211 case "md", "markdown", "mdown": 212 return "markdown" 213 case "html", "htm": 214 return "html" 215 default: 216 if in == "mmark" { 217 Deprecated("Markup type mmark", "See https://gohugo.io//content-management/formats/#list-of-content-formats", true) 218 } 219 if conv := c.Converters.Get(in); conv != nil { 220 return conv.Name() 221 } 222 } 223 return "" 224 } 225 226 // TotalWords counts instance of one or more consecutive white space 227 // characters, as defined by unicode.IsSpace, in s. 228 // This is a cheaper way of word counting than the obvious len(strings.Fields(s)). 229 func TotalWords(s string) int { 230 n := 0 231 inWord := false 232 for _, r := range s { 233 wasInWord := inWord 234 inWord = !unicode.IsSpace(r) 235 if inWord && !wasInWord { 236 n++ 237 } 238 } 239 return n 240 } 241 242 // TruncateWordsByRune truncates words by runes. 243 func (c *ContentSpec) TruncateWordsByRune(in []string) (string, bool) { 244 words := make([]string, len(in)) 245 copy(words, in) 246 247 count := 0 248 for index, word := range words { 249 if count >= c.summaryLength { 250 return strings.Join(words[:index], " "), true 251 } 252 runeCount := utf8.RuneCountInString(word) 253 if len(word) == runeCount { 254 count++ 255 } else if count+runeCount < c.summaryLength { 256 count += runeCount 257 } else { 258 for ri := range word { 259 if count >= c.summaryLength { 260 truncatedWords := append(words[:index], word[:ri]) 261 return strings.Join(truncatedWords, " "), true 262 } 263 count++ 264 } 265 } 266 } 267 268 return strings.Join(words, " "), false 269 } 270 271 // TruncateWordsToWholeSentence takes content and truncates to whole sentence 272 // limited by max number of words. It also returns whether it is truncated. 273 func (c *ContentSpec) TruncateWordsToWholeSentence(s string) (string, bool) { 274 var ( 275 wordCount = 0 276 lastWordIndex = -1 277 ) 278 279 for i, r := range s { 280 if unicode.IsSpace(r) { 281 wordCount++ 282 lastWordIndex = i 283 284 if wordCount >= c.summaryLength { 285 break 286 } 287 288 } 289 } 290 291 if lastWordIndex == -1 { 292 return s, false 293 } 294 295 endIndex := -1 296 297 for j, r := range s[lastWordIndex:] { 298 if isEndOfSentence(r) { 299 endIndex = j + lastWordIndex + utf8.RuneLen(r) 300 break 301 } 302 } 303 304 if endIndex == -1 { 305 return s, false 306 } 307 308 return strings.TrimSpace(s[:endIndex]), endIndex < len(s) 309 } 310 311 // TrimShortHTML removes the <p>/</p> tags from HTML input in the situation 312 // where said tags are the only <p> tags in the input and enclose the content 313 // of the input (whitespace excluded). 314 func (c *ContentSpec) TrimShortHTML(input []byte) []byte { 315 firstOpeningP := bytes.Index(input, paragraphIndicator) 316 lastOpeningP := bytes.LastIndex(input, paragraphIndicator) 317 318 lastClosingP := bytes.LastIndex(input, closingPTag) 319 lastClosing := bytes.LastIndex(input, closingIndicator) 320 321 if firstOpeningP == lastOpeningP && lastClosingP == lastClosing { 322 input = bytes.TrimSpace(input) 323 input = bytes.TrimPrefix(input, openingPTag) 324 input = bytes.TrimSuffix(input, closingPTag) 325 input = bytes.TrimSpace(input) 326 } 327 return input 328 } 329 330 func isEndOfSentence(r rune) bool { 331 return r == '.' || r == '?' || r == '!' || r == '"' || r == '\n' 332 } 333 334 // Kept only for benchmark. 335 func (c *ContentSpec) truncateWordsToWholeSentenceOld(content string) (string, bool) { 336 words := strings.Fields(content) 337 338 if c.summaryLength >= len(words) { 339 return strings.Join(words, " "), false 340 } 341 342 for counter, word := range words[c.summaryLength:] { 343 if strings.HasSuffix(word, ".") || 344 strings.HasSuffix(word, "?") || 345 strings.HasSuffix(word, ".\"") || 346 strings.HasSuffix(word, "!") { 347 upper := c.summaryLength + counter + 1 348 return strings.Join(words[:upper], " "), (upper < len(words)) 349 } 350 } 351 352 return strings.Join(words[:c.summaryLength], " "), true 353 }