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  }