github.com/dominikszabo/hugo-ds-clean@v0.47.1/helpers/content.go (about)

     1  // Copyright 2015 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  	"fmt"
    23  	"html/template"
    24  	"os/exec"
    25  	"unicode"
    26  	"unicode/utf8"
    27  
    28  	"github.com/gohugoio/hugo/common/maps"
    29  
    30  	"github.com/chaseadamsio/goorgeous"
    31  	bp "github.com/gohugoio/hugo/bufferpool"
    32  	"github.com/gohugoio/hugo/config"
    33  	"github.com/miekg/mmark"
    34  	"github.com/mitchellh/mapstructure"
    35  	"github.com/russross/blackfriday"
    36  	jww "github.com/spf13/jwalterweatherman"
    37  
    38  	"strings"
    39  )
    40  
    41  // SummaryDivider denotes where content summarization should end. The default is "<!--more-->".
    42  var SummaryDivider = []byte("<!--more-->")
    43  
    44  // ContentSpec provides functionality to render markdown content.
    45  type ContentSpec struct {
    46  	BlackFriday                *BlackFriday
    47  	footnoteAnchorPrefix       string
    48  	footnoteReturnLinkContents string
    49  	// SummaryLength is the length of the summary that Hugo extracts from a content.
    50  	summaryLength int
    51  
    52  	BuildFuture  bool
    53  	BuildExpired bool
    54  	BuildDrafts  bool
    55  
    56  	Highlight            func(code, lang, optsStr string) (string, error)
    57  	defatultPygmentsOpts map[string]string
    58  
    59  	cfg config.Provider
    60  }
    61  
    62  // NewContentSpec returns a ContentSpec initialized
    63  // with the appropriate fields from the given config.Provider.
    64  func NewContentSpec(cfg config.Provider) (*ContentSpec, error) {
    65  	bf := newBlackfriday(cfg.GetStringMap("blackfriday"))
    66  	spec := &ContentSpec{
    67  		BlackFriday:                bf,
    68  		footnoteAnchorPrefix:       cfg.GetString("footnoteAnchorPrefix"),
    69  		footnoteReturnLinkContents: cfg.GetString("footnoteReturnLinkContents"),
    70  		summaryLength:              cfg.GetInt("summaryLength"),
    71  		BuildFuture:                cfg.GetBool("buildFuture"),
    72  		BuildExpired:               cfg.GetBool("buildExpired"),
    73  		BuildDrafts:                cfg.GetBool("buildDrafts"),
    74  
    75  		cfg: cfg,
    76  	}
    77  
    78  	// Highlighting setup
    79  	options, err := parseDefaultPygmentsOpts(cfg)
    80  	if err != nil {
    81  		return nil, err
    82  	}
    83  	spec.defatultPygmentsOpts = options
    84  
    85  	// Use the Pygmentize on path if present
    86  	useClassic := false
    87  	h := newHiglighters(spec)
    88  
    89  	if cfg.GetBool("pygmentsUseClassic") {
    90  		if !hasPygments() {
    91  			jww.WARN.Println("Highlighting with pygmentsUseClassic set requires Pygments to be installed and in the path")
    92  		} else {
    93  			useClassic = true
    94  		}
    95  	}
    96  
    97  	if useClassic {
    98  		spec.Highlight = h.pygmentsHighlight
    99  	} else {
   100  		spec.Highlight = h.chromaHighlight
   101  	}
   102  
   103  	return spec, nil
   104  }
   105  
   106  // BlackFriday holds configuration values for BlackFriday rendering.
   107  type BlackFriday struct {
   108  	Smartypants           bool
   109  	SmartypantsQuotesNBSP bool
   110  	AngledQuotes          bool
   111  	Fractions             bool
   112  	HrefTargetBlank       bool
   113  	NofollowLinks         bool
   114  	NoreferrerLinks       bool
   115  	SmartDashes           bool
   116  	LatexDashes           bool
   117  	TaskLists             bool
   118  	PlainIDAnchors        bool
   119  	Extensions            []string
   120  	ExtensionsMask        []string
   121  }
   122  
   123  // NewBlackfriday creates a new Blackfriday filled with site config or some sane defaults.
   124  func newBlackfriday(config map[string]interface{}) *BlackFriday {
   125  	defaultParam := map[string]interface{}{
   126  		"smartypants":           true,
   127  		"angledQuotes":          false,
   128  		"smartypantsQuotesNBSP": false,
   129  		"fractions":             true,
   130  		"hrefTargetBlank":       false,
   131  		"nofollowLinks":         false,
   132  		"noreferrerLinks":       false,
   133  		"smartDashes":           true,
   134  		"latexDashes":           true,
   135  		"plainIDAnchors":        true,
   136  		"taskLists":             true,
   137  	}
   138  
   139  	maps.ToLower(defaultParam)
   140  
   141  	siteConfig := make(map[string]interface{})
   142  
   143  	for k, v := range defaultParam {
   144  		siteConfig[k] = v
   145  	}
   146  
   147  	if config != nil {
   148  		for k, v := range config {
   149  			siteConfig[k] = v
   150  		}
   151  	}
   152  
   153  	combinedConfig := &BlackFriday{}
   154  	if err := mapstructure.Decode(siteConfig, combinedConfig); err != nil {
   155  		jww.FATAL.Printf("Failed to get site rendering config\n%s", err.Error())
   156  	}
   157  
   158  	return combinedConfig
   159  }
   160  
   161  var blackfridayExtensionMap = map[string]int{
   162  	"noIntraEmphasis":        blackfriday.EXTENSION_NO_INTRA_EMPHASIS,
   163  	"tables":                 blackfriday.EXTENSION_TABLES,
   164  	"fencedCode":             blackfriday.EXTENSION_FENCED_CODE,
   165  	"autolink":               blackfriday.EXTENSION_AUTOLINK,
   166  	"strikethrough":          blackfriday.EXTENSION_STRIKETHROUGH,
   167  	"laxHtmlBlocks":          blackfriday.EXTENSION_LAX_HTML_BLOCKS,
   168  	"spaceHeaders":           blackfriday.EXTENSION_SPACE_HEADERS,
   169  	"hardLineBreak":          blackfriday.EXTENSION_HARD_LINE_BREAK,
   170  	"tabSizeEight":           blackfriday.EXTENSION_TAB_SIZE_EIGHT,
   171  	"footnotes":              blackfriday.EXTENSION_FOOTNOTES,
   172  	"noEmptyLineBeforeBlock": blackfriday.EXTENSION_NO_EMPTY_LINE_BEFORE_BLOCK,
   173  	"headerIds":              blackfriday.EXTENSION_HEADER_IDS,
   174  	"titleblock":             blackfriday.EXTENSION_TITLEBLOCK,
   175  	"autoHeaderIds":          blackfriday.EXTENSION_AUTO_HEADER_IDS,
   176  	"backslashLineBreak":     blackfriday.EXTENSION_BACKSLASH_LINE_BREAK,
   177  	"definitionLists":        blackfriday.EXTENSION_DEFINITION_LISTS,
   178  	"joinLines":              blackfriday.EXTENSION_JOIN_LINES,
   179  }
   180  
   181  var stripHTMLReplacer = strings.NewReplacer("\n", " ", "</p>", "\n", "<br>", "\n", "<br />", "\n")
   182  
   183  var mmarkExtensionMap = map[string]int{
   184  	"tables":                 mmark.EXTENSION_TABLES,
   185  	"fencedCode":             mmark.EXTENSION_FENCED_CODE,
   186  	"autolink":               mmark.EXTENSION_AUTOLINK,
   187  	"laxHtmlBlocks":          mmark.EXTENSION_LAX_HTML_BLOCKS,
   188  	"spaceHeaders":           mmark.EXTENSION_SPACE_HEADERS,
   189  	"hardLineBreak":          mmark.EXTENSION_HARD_LINE_BREAK,
   190  	"footnotes":              mmark.EXTENSION_FOOTNOTES,
   191  	"noEmptyLineBeforeBlock": mmark.EXTENSION_NO_EMPTY_LINE_BEFORE_BLOCK,
   192  	"headerIds":              mmark.EXTENSION_HEADER_IDS,
   193  	"autoHeaderIds":          mmark.EXTENSION_AUTO_HEADER_IDS,
   194  }
   195  
   196  // StripHTML accepts a string, strips out all HTML tags and returns it.
   197  func StripHTML(s string) string {
   198  
   199  	// Shortcut strings with no tags in them
   200  	if !strings.ContainsAny(s, "<>") {
   201  		return s
   202  	}
   203  	s = stripHTMLReplacer.Replace(s)
   204  
   205  	// Walk through the string removing all tags
   206  	b := bp.GetBuffer()
   207  	defer bp.PutBuffer(b)
   208  	var inTag, isSpace, wasSpace bool
   209  	for _, r := range s {
   210  		if !inTag {
   211  			isSpace = false
   212  		}
   213  
   214  		switch {
   215  		case r == '<':
   216  			inTag = true
   217  		case r == '>':
   218  			inTag = false
   219  		case unicode.IsSpace(r):
   220  			isSpace = true
   221  			fallthrough
   222  		default:
   223  			if !inTag && (!isSpace || (isSpace && !wasSpace)) {
   224  				b.WriteRune(r)
   225  			}
   226  		}
   227  
   228  		wasSpace = isSpace
   229  
   230  	}
   231  	return b.String()
   232  }
   233  
   234  // stripEmptyNav strips out empty <nav> tags from content.
   235  func stripEmptyNav(in []byte) []byte {
   236  	return bytes.Replace(in, []byte("<nav>\n</nav>\n\n"), []byte(``), -1)
   237  }
   238  
   239  // BytesToHTML converts bytes to type template.HTML.
   240  func BytesToHTML(b []byte) template.HTML {
   241  	return template.HTML(string(b))
   242  }
   243  
   244  // getHTMLRenderer creates a new Blackfriday HTML Renderer with the given configuration.
   245  func (c *ContentSpec) getHTMLRenderer(defaultFlags int, ctx *RenderingContext) blackfriday.Renderer {
   246  	renderParameters := blackfriday.HtmlRendererParameters{
   247  		FootnoteAnchorPrefix:       c.footnoteAnchorPrefix,
   248  		FootnoteReturnLinkContents: c.footnoteReturnLinkContents,
   249  	}
   250  
   251  	b := len(ctx.DocumentID) != 0
   252  
   253  	if ctx.Config == nil {
   254  		panic(fmt.Sprintf("RenderingContext of %q doesn't have a config", ctx.DocumentID))
   255  	}
   256  
   257  	if b && !ctx.Config.PlainIDAnchors {
   258  		renderParameters.FootnoteAnchorPrefix = ctx.DocumentID + ":" + renderParameters.FootnoteAnchorPrefix
   259  		renderParameters.HeaderIDSuffix = ":" + ctx.DocumentID
   260  	}
   261  
   262  	htmlFlags := defaultFlags
   263  	htmlFlags |= blackfriday.HTML_USE_XHTML
   264  	htmlFlags |= blackfriday.HTML_FOOTNOTE_RETURN_LINKS
   265  
   266  	if ctx.Config.Smartypants {
   267  		htmlFlags |= blackfriday.HTML_USE_SMARTYPANTS
   268  	}
   269  
   270  	if ctx.Config.SmartypantsQuotesNBSP {
   271  		htmlFlags |= blackfriday.HTML_SMARTYPANTS_QUOTES_NBSP
   272  	}
   273  
   274  	if ctx.Config.AngledQuotes {
   275  		htmlFlags |= blackfriday.HTML_SMARTYPANTS_ANGLED_QUOTES
   276  	}
   277  
   278  	if ctx.Config.Fractions {
   279  		htmlFlags |= blackfriday.HTML_SMARTYPANTS_FRACTIONS
   280  	}
   281  
   282  	if ctx.Config.HrefTargetBlank {
   283  		htmlFlags |= blackfriday.HTML_HREF_TARGET_BLANK
   284  	}
   285  
   286  	if ctx.Config.NofollowLinks {
   287  		htmlFlags |= blackfriday.HTML_NOFOLLOW_LINKS
   288  	}
   289  
   290  	if ctx.Config.NoreferrerLinks {
   291  		htmlFlags |= blackfriday.HTML_NOREFERRER_LINKS
   292  	}
   293  
   294  	if ctx.Config.SmartDashes {
   295  		htmlFlags |= blackfriday.HTML_SMARTYPANTS_DASHES
   296  	}
   297  
   298  	if ctx.Config.LatexDashes {
   299  		htmlFlags |= blackfriday.HTML_SMARTYPANTS_LATEX_DASHES
   300  	}
   301  
   302  	return &HugoHTMLRenderer{
   303  		cs:               c,
   304  		RenderingContext: ctx,
   305  		Renderer:         blackfriday.HtmlRendererWithParameters(htmlFlags, "", "", renderParameters),
   306  	}
   307  }
   308  
   309  func getMarkdownExtensions(ctx *RenderingContext) int {
   310  	// Default Blackfriday common extensions
   311  	commonExtensions := 0 |
   312  		blackfriday.EXTENSION_NO_INTRA_EMPHASIS |
   313  		blackfriday.EXTENSION_TABLES |
   314  		blackfriday.EXTENSION_FENCED_CODE |
   315  		blackfriday.EXTENSION_AUTOLINK |
   316  		blackfriday.EXTENSION_STRIKETHROUGH |
   317  		blackfriday.EXTENSION_SPACE_HEADERS |
   318  		blackfriday.EXTENSION_HEADER_IDS |
   319  		blackfriday.EXTENSION_BACKSLASH_LINE_BREAK |
   320  		blackfriday.EXTENSION_DEFINITION_LISTS
   321  
   322  	// Extra Blackfriday extensions that Hugo enables by default
   323  	flags := commonExtensions |
   324  		blackfriday.EXTENSION_AUTO_HEADER_IDS |
   325  		blackfriday.EXTENSION_FOOTNOTES
   326  
   327  	if ctx.Config == nil {
   328  		panic(fmt.Sprintf("RenderingContext of %q doesn't have a config", ctx.DocumentID))
   329  	}
   330  
   331  	for _, extension := range ctx.Config.Extensions {
   332  		if flag, ok := blackfridayExtensionMap[extension]; ok {
   333  			flags |= flag
   334  		}
   335  	}
   336  	for _, extension := range ctx.Config.ExtensionsMask {
   337  		if flag, ok := blackfridayExtensionMap[extension]; ok {
   338  			flags &= ^flag
   339  		}
   340  	}
   341  	return flags
   342  }
   343  
   344  func (c ContentSpec) markdownRender(ctx *RenderingContext) []byte {
   345  	if ctx.RenderTOC {
   346  		return blackfriday.Markdown(ctx.Content,
   347  			c.getHTMLRenderer(blackfriday.HTML_TOC, ctx),
   348  			getMarkdownExtensions(ctx))
   349  	}
   350  	return blackfriday.Markdown(ctx.Content, c.getHTMLRenderer(0, ctx),
   351  		getMarkdownExtensions(ctx))
   352  }
   353  
   354  // getMmarkHTMLRenderer creates a new mmark HTML Renderer with the given configuration.
   355  func (c *ContentSpec) getMmarkHTMLRenderer(defaultFlags int, ctx *RenderingContext) mmark.Renderer {
   356  	renderParameters := mmark.HtmlRendererParameters{
   357  		FootnoteAnchorPrefix:       c.footnoteAnchorPrefix,
   358  		FootnoteReturnLinkContents: c.footnoteReturnLinkContents,
   359  	}
   360  
   361  	b := len(ctx.DocumentID) != 0
   362  
   363  	if ctx.Config == nil {
   364  		panic(fmt.Sprintf("RenderingContext of %q doesn't have a config", ctx.DocumentID))
   365  	}
   366  
   367  	if b && !ctx.Config.PlainIDAnchors {
   368  		renderParameters.FootnoteAnchorPrefix = ctx.DocumentID + ":" + renderParameters.FootnoteAnchorPrefix
   369  		// renderParameters.HeaderIDSuffix = ":" + ctx.DocumentId
   370  	}
   371  
   372  	htmlFlags := defaultFlags
   373  	htmlFlags |= mmark.HTML_FOOTNOTE_RETURN_LINKS
   374  
   375  	return &HugoMmarkHTMLRenderer{
   376  		cs:       c,
   377  		Renderer: mmark.HtmlRendererWithParameters(htmlFlags, "", "", renderParameters),
   378  		Cfg:      c.cfg,
   379  	}
   380  }
   381  
   382  func getMmarkExtensions(ctx *RenderingContext) int {
   383  	flags := 0
   384  	flags |= mmark.EXTENSION_TABLES
   385  	flags |= mmark.EXTENSION_FENCED_CODE
   386  	flags |= mmark.EXTENSION_AUTOLINK
   387  	flags |= mmark.EXTENSION_SPACE_HEADERS
   388  	flags |= mmark.EXTENSION_CITATION
   389  	flags |= mmark.EXTENSION_TITLEBLOCK_TOML
   390  	flags |= mmark.EXTENSION_HEADER_IDS
   391  	flags |= mmark.EXTENSION_AUTO_HEADER_IDS
   392  	flags |= mmark.EXTENSION_UNIQUE_HEADER_IDS
   393  	flags |= mmark.EXTENSION_FOOTNOTES
   394  	flags |= mmark.EXTENSION_SHORT_REF
   395  	flags |= mmark.EXTENSION_NO_EMPTY_LINE_BEFORE_BLOCK
   396  	flags |= mmark.EXTENSION_INCLUDE
   397  
   398  	if ctx.Config == nil {
   399  		panic(fmt.Sprintf("RenderingContext of %q doesn't have a config", ctx.DocumentID))
   400  	}
   401  
   402  	for _, extension := range ctx.Config.Extensions {
   403  		if flag, ok := mmarkExtensionMap[extension]; ok {
   404  			flags |= flag
   405  		}
   406  	}
   407  	return flags
   408  }
   409  
   410  func (c ContentSpec) mmarkRender(ctx *RenderingContext) []byte {
   411  	return mmark.Parse(ctx.Content, c.getMmarkHTMLRenderer(0, ctx),
   412  		getMmarkExtensions(ctx)).Bytes()
   413  }
   414  
   415  // ExtractTOC extracts Table of Contents from content.
   416  func ExtractTOC(content []byte) (newcontent []byte, toc []byte) {
   417  	if !bytes.Contains(content, []byte("<nav>")) {
   418  		return content, nil
   419  	}
   420  	origContent := make([]byte, len(content))
   421  	copy(origContent, content)
   422  	first := []byte(`<nav>
   423  <ul>`)
   424  
   425  	last := []byte(`</ul>
   426  </nav>`)
   427  
   428  	replacement := []byte(`<nav id="TableOfContents">
   429  <ul>`)
   430  
   431  	startOfTOC := bytes.Index(content, first)
   432  
   433  	peekEnd := len(content)
   434  	if peekEnd > 70+startOfTOC {
   435  		peekEnd = 70 + startOfTOC
   436  	}
   437  
   438  	if startOfTOC < 0 {
   439  		return stripEmptyNav(content), toc
   440  	}
   441  	// Need to peek ahead to see if this nav element is actually the right one.
   442  	correctNav := bytes.Index(content[startOfTOC:peekEnd], []byte(`<li><a href="#`))
   443  	if correctNav < 0 { // no match found
   444  		return content, toc
   445  	}
   446  	lengthOfTOC := bytes.Index(content[startOfTOC:], last) + len(last)
   447  	endOfTOC := startOfTOC + lengthOfTOC
   448  
   449  	newcontent = append(content[:startOfTOC], content[endOfTOC:]...)
   450  	toc = append(replacement, origContent[startOfTOC+len(first):endOfTOC]...)
   451  	return
   452  }
   453  
   454  // RenderingContext holds contextual information, like content and configuration,
   455  // for a given content rendering.
   456  // By creating you must set the Config, otherwise it will panic.
   457  type RenderingContext struct {
   458  	Content      []byte
   459  	PageFmt      string
   460  	DocumentID   string
   461  	DocumentName string
   462  	Config       *BlackFriday
   463  	RenderTOC    bool
   464  	Cfg          config.Provider
   465  }
   466  
   467  // RenderBytes renders a []byte.
   468  func (c ContentSpec) RenderBytes(ctx *RenderingContext) []byte {
   469  	switch ctx.PageFmt {
   470  	default:
   471  		return c.markdownRender(ctx)
   472  	case "markdown":
   473  		return c.markdownRender(ctx)
   474  	case "asciidoc":
   475  		return getAsciidocContent(ctx)
   476  	case "mmark":
   477  		return c.mmarkRender(ctx)
   478  	case "rst":
   479  		return getRstContent(ctx)
   480  	case "org":
   481  		return orgRender(ctx, c)
   482  	case "pandoc":
   483  		return getPandocContent(ctx)
   484  	}
   485  }
   486  
   487  // TotalWords counts instance of one or more consecutive white space
   488  // characters, as defined by unicode.IsSpace, in s.
   489  // This is a cheaper way of word counting than the obvious len(strings.Fields(s)).
   490  func TotalWords(s string) int {
   491  	n := 0
   492  	inWord := false
   493  	for _, r := range s {
   494  		wasInWord := inWord
   495  		inWord = !unicode.IsSpace(r)
   496  		if inWord && !wasInWord {
   497  			n++
   498  		}
   499  	}
   500  	return n
   501  }
   502  
   503  // Old implementation only kept for benchmark comparison.
   504  // TODO(bep) remove
   505  func totalWordsOld(s string) int {
   506  	return len(strings.Fields(s))
   507  }
   508  
   509  // TruncateWordsByRune truncates words by runes.
   510  func (c *ContentSpec) TruncateWordsByRune(in []string) (string, bool) {
   511  	words := make([]string, len(in))
   512  	copy(words, in)
   513  
   514  	count := 0
   515  	for index, word := range words {
   516  		if count >= c.summaryLength {
   517  			return strings.Join(words[:index], " "), true
   518  		}
   519  		runeCount := utf8.RuneCountInString(word)
   520  		if len(word) == runeCount {
   521  			count++
   522  		} else if count+runeCount < c.summaryLength {
   523  			count += runeCount
   524  		} else {
   525  			for ri := range word {
   526  				if count >= c.summaryLength {
   527  					truncatedWords := append(words[:index], word[:ri])
   528  					return strings.Join(truncatedWords, " "), true
   529  				}
   530  				count++
   531  			}
   532  		}
   533  	}
   534  
   535  	return strings.Join(words, " "), false
   536  }
   537  
   538  // TruncateWordsToWholeSentence takes content and truncates to whole sentence
   539  // limited by max number of words. It also returns whether it is truncated.
   540  func (c *ContentSpec) TruncateWordsToWholeSentence(s string) (string, bool) {
   541  	var (
   542  		wordCount     = 0
   543  		lastWordIndex = -1
   544  	)
   545  
   546  	for i, r := range s {
   547  		if unicode.IsSpace(r) {
   548  			wordCount++
   549  			lastWordIndex = i
   550  
   551  			if wordCount >= c.summaryLength {
   552  				break
   553  			}
   554  
   555  		}
   556  	}
   557  
   558  	if lastWordIndex == -1 {
   559  		return s, false
   560  	}
   561  
   562  	endIndex := -1
   563  
   564  	for j, r := range s[lastWordIndex:] {
   565  		if isEndOfSentence(r) {
   566  			endIndex = j + lastWordIndex + utf8.RuneLen(r)
   567  			break
   568  		}
   569  	}
   570  
   571  	if endIndex == -1 {
   572  		return s, false
   573  	}
   574  
   575  	return strings.TrimSpace(s[:endIndex]), endIndex < len(s)
   576  }
   577  
   578  func isEndOfSentence(r rune) bool {
   579  	return r == '.' || r == '?' || r == '!' || r == '"' || r == '\n'
   580  }
   581  
   582  // Kept only for benchmark.
   583  func (c *ContentSpec) truncateWordsToWholeSentenceOld(content string) (string, bool) {
   584  	words := strings.Fields(content)
   585  
   586  	if c.summaryLength >= len(words) {
   587  		return strings.Join(words, " "), false
   588  	}
   589  
   590  	for counter, word := range words[c.summaryLength:] {
   591  		if strings.HasSuffix(word, ".") ||
   592  			strings.HasSuffix(word, "?") ||
   593  			strings.HasSuffix(word, ".\"") ||
   594  			strings.HasSuffix(word, "!") {
   595  			upper := c.summaryLength + counter + 1
   596  			return strings.Join(words[:upper], " "), (upper < len(words))
   597  		}
   598  	}
   599  
   600  	return strings.Join(words[:c.summaryLength], " "), true
   601  }
   602  
   603  func getAsciidocExecPath() string {
   604  	path, err := exec.LookPath("asciidoc")
   605  	if err != nil {
   606  		return ""
   607  	}
   608  	return path
   609  }
   610  
   611  func getAsciidoctorExecPath() string {
   612  	path, err := exec.LookPath("asciidoctor")
   613  	if err != nil {
   614  		return ""
   615  	}
   616  	return path
   617  }
   618  
   619  // HasAsciidoc returns whether Asciidoc or Asciidoctor is installed on this computer.
   620  func HasAsciidoc() bool {
   621  	return (getAsciidoctorExecPath() != "" ||
   622  		getAsciidocExecPath() != "")
   623  }
   624  
   625  // getAsciidocContent calls asciidoctor or asciidoc as an external helper
   626  // to convert AsciiDoc content to HTML.
   627  func getAsciidocContent(ctx *RenderingContext) []byte {
   628  	var isAsciidoctor bool
   629  	path := getAsciidoctorExecPath()
   630  	if path == "" {
   631  		path = getAsciidocExecPath()
   632  		if path == "" {
   633  			jww.ERROR.Println("asciidoctor / asciidoc not found in $PATH: Please install.\n",
   634  				"                 Leaving AsciiDoc content unrendered.")
   635  			return ctx.Content
   636  		}
   637  	} else {
   638  		isAsciidoctor = true
   639  	}
   640  
   641  	jww.INFO.Println("Rendering", ctx.DocumentName, "with", path, "...")
   642  	args := []string{"--no-header-footer", "--safe"}
   643  	if isAsciidoctor {
   644  		// asciidoctor-specific arg to show stack traces on errors
   645  		args = append(args, "--trace")
   646  	}
   647  	args = append(args, "-")
   648  	return externallyRenderContent(ctx, path, args)
   649  }
   650  
   651  // HasRst returns whether rst2html is installed on this computer.
   652  func HasRst() bool {
   653  	return getRstExecPath() != ""
   654  }
   655  
   656  func getRstExecPath() string {
   657  	path, err := exec.LookPath("rst2html")
   658  	if err != nil {
   659  		path, err = exec.LookPath("rst2html.py")
   660  		if err != nil {
   661  			return ""
   662  		}
   663  	}
   664  	return path
   665  }
   666  
   667  func getPythonExecPath() string {
   668  	path, err := exec.LookPath("python")
   669  	if err != nil {
   670  		path, err = exec.LookPath("python.exe")
   671  		if err != nil {
   672  			return ""
   673  		}
   674  	}
   675  	return path
   676  }
   677  
   678  // getRstContent calls the Python script rst2html as an external helper
   679  // to convert reStructuredText content to HTML.
   680  func getRstContent(ctx *RenderingContext) []byte {
   681  	python := getPythonExecPath()
   682  	path := getRstExecPath()
   683  
   684  	if path == "" {
   685  		jww.ERROR.Println("rst2html / rst2html.py not found in $PATH: Please install.\n",
   686  			"                 Leaving reStructuredText content unrendered.")
   687  		return ctx.Content
   688  
   689  	}
   690  	jww.INFO.Println("Rendering", ctx.DocumentName, "with", path, "...")
   691  	args := []string{path, "--leave-comments", "--initial-header-level=2"}
   692  	result := externallyRenderContent(ctx, python, args)
   693  	// TODO(bep) check if rst2html has a body only option.
   694  	bodyStart := bytes.Index(result, []byte("<body>\n"))
   695  	if bodyStart < 0 {
   696  		bodyStart = -7 //compensate for length
   697  	}
   698  
   699  	bodyEnd := bytes.Index(result, []byte("\n</body>"))
   700  	if bodyEnd < 0 || bodyEnd >= len(result) {
   701  		bodyEnd = len(result) - 1
   702  		if bodyEnd < 0 {
   703  			bodyEnd = 0
   704  		}
   705  	}
   706  
   707  	return result[bodyStart+7 : bodyEnd]
   708  }
   709  
   710  // getPandocContent calls pandoc as an external helper to convert pandoc markdown to HTML.
   711  func getPandocContent(ctx *RenderingContext) []byte {
   712  	path, err := exec.LookPath("pandoc")
   713  	if err != nil {
   714  		jww.ERROR.Println("pandoc not found in $PATH: Please install.\n",
   715  			"                 Leaving pandoc content unrendered.")
   716  		return ctx.Content
   717  	}
   718  	args := []string{"--mathjax"}
   719  	return externallyRenderContent(ctx, path, args)
   720  }
   721  
   722  func orgRender(ctx *RenderingContext, c ContentSpec) []byte {
   723  	content := ctx.Content
   724  	cleanContent := bytes.Replace(content, []byte("# more"), []byte(""), 1)
   725  	return goorgeous.Org(cleanContent,
   726  		c.getHTMLRenderer(blackfriday.HTML_TOC, ctx))
   727  }
   728  
   729  func externallyRenderContent(ctx *RenderingContext, path string, args []string) []byte {
   730  	content := ctx.Content
   731  	cleanContent := bytes.Replace(content, SummaryDivider, []byte(""), 1)
   732  
   733  	cmd := exec.Command(path, args...)
   734  	cmd.Stdin = bytes.NewReader(cleanContent)
   735  	var out, cmderr bytes.Buffer
   736  	cmd.Stdout = &out
   737  	cmd.Stderr = &cmderr
   738  	err := cmd.Run()
   739  	// Most external helpers exit w/ non-zero exit code only if severe, i.e.
   740  	// halting errors occurred. -> log stderr output regardless of state of err
   741  	for _, item := range strings.Split(string(cmderr.Bytes()), "\n") {
   742  		item := strings.TrimSpace(item)
   743  		if item != "" {
   744  			jww.ERROR.Printf("%s: %s", ctx.DocumentName, item)
   745  		}
   746  	}
   747  	if err != nil {
   748  		jww.ERROR.Printf("%s rendering %s: %v", path, ctx.DocumentName, err)
   749  	}
   750  
   751  	return normalizeExternalHelperLineFeeds(out.Bytes())
   752  }