github.com/neohugo/neohugo@v0.123.8/hugolib/page__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 hugolib
    15  
    16  import (
    17  	"bytes"
    18  	"context"
    19  	"errors"
    20  	"fmt"
    21  	"html/template"
    22  	"io"
    23  	"strconv"
    24  	"strings"
    25  	"unicode/utf8"
    26  
    27  	"github.com/bep/logg"
    28  	"github.com/neohugo/neohugo/common/hcontext"
    29  	"github.com/neohugo/neohugo/common/herrors"
    30  	"github.com/neohugo/neohugo/common/hugio"
    31  	"github.com/neohugo/neohugo/helpers"
    32  	"github.com/neohugo/neohugo/identity"
    33  	"github.com/neohugo/neohugo/markup/converter"
    34  	"github.com/neohugo/neohugo/markup/tableofcontents"
    35  	"github.com/neohugo/neohugo/parser/metadecoders"
    36  	"github.com/neohugo/neohugo/parser/pageparser"
    37  	"github.com/neohugo/neohugo/resources"
    38  	"github.com/neohugo/neohugo/resources/resource"
    39  	"github.com/neohugo/neohugo/tpl"
    40  )
    41  
    42  const (
    43  	internalSummaryDividerBase = "HUGOMORE42"
    44  )
    45  
    46  var (
    47  	internalSummaryDividerBaseBytes = []byte(internalSummaryDividerBase)
    48  	internalSummaryDividerPre       = []byte("\n\n" + internalSummaryDividerBase + "\n\n")
    49  )
    50  
    51  type pageContentReplacement struct {
    52  	val []byte
    53  
    54  	source pageparser.Item
    55  }
    56  
    57  func (m *pageMeta) parseFrontMatter(h *HugoSites, pid uint64, sourceKey string) (*contentParseInfo, error) {
    58  	var openSource hugio.OpenReadSeekCloser
    59  	if m.f != nil {
    60  		meta := m.f.FileInfo().Meta()
    61  		openSource = func() (hugio.ReadSeekCloser, error) {
    62  			r, err := meta.Open()
    63  			if err != nil {
    64  				return nil, fmt.Errorf("failed to open file %q: %w", meta.Filename, err)
    65  			}
    66  			return r, nil
    67  		}
    68  	}
    69  
    70  	if sourceKey == "" {
    71  		sourceKey = strconv.Itoa(int(pid))
    72  	}
    73  
    74  	pi := &contentParseInfo{
    75  		h:          h,
    76  		pid:        pid,
    77  		sourceKey:  sourceKey,
    78  		openSource: openSource,
    79  	}
    80  
    81  	source, err := pi.contentSource(m)
    82  	if err != nil {
    83  		return nil, err
    84  	}
    85  
    86  	items, err := pageparser.ParseBytes(
    87  		source,
    88  		pageparser.Config{},
    89  	)
    90  	if err != nil {
    91  		return nil, err
    92  	}
    93  
    94  	pi.itemsStep1 = items
    95  
    96  	if err := pi.mapFrontMatter(source); err != nil {
    97  		return nil, err
    98  	}
    99  
   100  	return pi, nil
   101  }
   102  
   103  func (m *pageMeta) newCachedContent(h *HugoSites, pi *contentParseInfo) (*cachedContent, error) {
   104  	var filename string
   105  	if m.f != nil {
   106  		filename = m.f.Filename()
   107  	}
   108  
   109  	c := &cachedContent{
   110  		pm:             m.s.pageMap,
   111  		StaleInfo:      m,
   112  		shortcodeState: newShortcodeHandler(filename, m.s),
   113  		pi:             pi,
   114  		enableEmoji:    m.s.conf.EnableEmoji,
   115  	}
   116  
   117  	source, err := c.pi.contentSource(m)
   118  	if err != nil {
   119  		return nil, err
   120  	}
   121  
   122  	if err := c.parseContentFile(source); err != nil {
   123  		return nil, err
   124  	}
   125  
   126  	return c, nil
   127  }
   128  
   129  type cachedContent struct {
   130  	pm *pageMap
   131  
   132  	resource.StaleInfo
   133  
   134  	shortcodeState *shortcodeHandler
   135  
   136  	// Parsed content.
   137  	pi *contentParseInfo
   138  
   139  	enableEmoji bool
   140  }
   141  
   142  type contentParseInfo struct {
   143  	h *HugoSites
   144  
   145  	pid       uint64
   146  	sourceKey string
   147  
   148  	// The source bytes.
   149  	openSource hugio.OpenReadSeekCloser
   150  
   151  	frontMatter map[string]any
   152  
   153  	// Whether the parsed content contains a summary separator.
   154  	hasSummaryDivider bool
   155  
   156  	// Whether there are more content after the summary divider.
   157  	summaryTruncated bool
   158  
   159  	// Returns the position in bytes after any front matter.
   160  	posMainContent int
   161  
   162  	// Indicates whether we must do placeholder replacements.
   163  	hasNonMarkdownShortcode bool
   164  
   165  	// Items from the page parser.
   166  	// These maps directly to the source
   167  	itemsStep1 pageparser.Items
   168  
   169  	//  *shortcode, pageContentReplacement or pageparser.Item
   170  	itemsStep2 []any
   171  }
   172  
   173  func (p *contentParseInfo) AddBytes(item pageparser.Item) {
   174  	p.itemsStep2 = append(p.itemsStep2, item)
   175  }
   176  
   177  func (p *contentParseInfo) AddReplacement(val []byte, source pageparser.Item) {
   178  	p.itemsStep2 = append(p.itemsStep2, pageContentReplacement{val: val, source: source})
   179  }
   180  
   181  func (p *contentParseInfo) AddShortcode(s *shortcode) {
   182  	p.itemsStep2 = append(p.itemsStep2, s)
   183  	if s.insertPlaceholder() {
   184  		p.hasNonMarkdownShortcode = true
   185  	}
   186  }
   187  
   188  // contentToRenderForItems returns the content to be processed by Goldmark or similar.
   189  func (pi *contentParseInfo) contentToRender(ctx context.Context, source []byte, renderedShortcodes map[string]shortcodeRenderer) ([]byte, bool, error) {
   190  	var hasVariants bool
   191  	c := make([]byte, 0, len(source)+(len(source)/10))
   192  
   193  	for _, it := range pi.itemsStep2 {
   194  		switch v := it.(type) {
   195  		case pageparser.Item:
   196  			c = append(c, source[v.Pos():v.Pos()+len(v.Val(source))]...)
   197  		case pageContentReplacement:
   198  			c = append(c, v.val...)
   199  		case *shortcode:
   200  			if !v.insertPlaceholder() {
   201  				// Insert the rendered shortcode.
   202  				renderedShortcode, found := renderedShortcodes[v.placeholder]
   203  				if !found {
   204  					// This should never happen.
   205  					panic(fmt.Sprintf("rendered shortcode %q not found", v.placeholder))
   206  				}
   207  
   208  				b, more, err := renderedShortcode.renderShortcode(ctx)
   209  				if err != nil {
   210  					return nil, false, fmt.Errorf("failed to render shortcode: %w", err)
   211  				}
   212  				hasVariants = hasVariants || more
   213  				c = append(c, []byte(b)...)
   214  
   215  			} else {
   216  				// Insert the placeholder so we can insert the content after
   217  				// markdown processing.
   218  				c = append(c, []byte(v.placeholder)...)
   219  			}
   220  		default:
   221  			panic(fmt.Sprintf("unknown item type %T", it))
   222  		}
   223  	}
   224  
   225  	return c, hasVariants, nil
   226  }
   227  
   228  func (c *cachedContent) IsZero() bool {
   229  	return len(c.pi.itemsStep2) == 0
   230  }
   231  
   232  func (c *cachedContent) parseContentFile(source []byte) error {
   233  	if source == nil || c.pi.openSource == nil {
   234  		return nil
   235  	}
   236  
   237  	return c.pi.mapItemsAfterFrontMatter(source, c.shortcodeState)
   238  }
   239  
   240  func (c *contentParseInfo) parseFrontMatter(it pageparser.Item, iter *pageparser.Iterator, source []byte) error {
   241  	if c.frontMatter != nil {
   242  		return nil
   243  	}
   244  
   245  	f := pageparser.FormatFromFrontMatterType(it.Type)
   246  	var err error
   247  	c.frontMatter, err = metadecoders.Default.UnmarshalToMap(it.Val(source), f)
   248  	if err != nil {
   249  		if fe, ok := err.(herrors.FileError); ok {
   250  			pos := fe.Position()
   251  
   252  			// Offset the starting position of front matter.
   253  			offset := iter.LineNumber(source) - 1
   254  			if f == metadecoders.YAML {
   255  				offset -= 1
   256  			}
   257  			pos.LineNumber += offset
   258  
   259  			fe.UpdatePosition(pos) // nolint
   260  			fe.SetFilename("")     // nolint It will be set later.
   261  
   262  			return fe
   263  		} else {
   264  			return err
   265  		}
   266  	}
   267  
   268  	return nil
   269  }
   270  
   271  func (rn *contentParseInfo) failMap(source []byte, err error, i pageparser.Item) error {
   272  	if fe, ok := err.(herrors.FileError); ok {
   273  		return fe
   274  	}
   275  
   276  	pos := posFromInput("", source, i.Pos())
   277  
   278  	return herrors.NewFileErrorFromPos(err, pos)
   279  }
   280  
   281  func (rn *contentParseInfo) mapFrontMatter(source []byte) error {
   282  	if len(rn.itemsStep1) == 0 {
   283  		return nil
   284  	}
   285  	iter := pageparser.NewIterator(rn.itemsStep1)
   286  
   287  Loop:
   288  	for {
   289  		it := iter.Next()
   290  		switch {
   291  		case it.IsFrontMatter():
   292  			if err := rn.parseFrontMatter(it, iter, source); err != nil {
   293  				return err
   294  			}
   295  			next := iter.Peek()
   296  			if !next.IsDone() {
   297  				rn.posMainContent = next.Pos()
   298  			}
   299  			// Done.
   300  			break Loop
   301  		case it.IsEOF():
   302  			break Loop
   303  		case it.IsError():
   304  			return rn.failMap(source, it.Err, it)
   305  		default:
   306  
   307  		}
   308  	}
   309  
   310  	return nil
   311  }
   312  
   313  func (rn *contentParseInfo) mapItemsAfterFrontMatter(
   314  	source []byte,
   315  	s *shortcodeHandler,
   316  ) error {
   317  	if len(rn.itemsStep1) == 0 {
   318  		return nil
   319  	}
   320  
   321  	fail := func(err error, i pageparser.Item) error {
   322  		if fe, ok := err.(herrors.FileError); ok {
   323  			return fe
   324  		}
   325  
   326  		pos := posFromInput("", source, i.Pos())
   327  
   328  		return herrors.NewFileErrorFromPos(err, pos)
   329  	}
   330  
   331  	iter := pageparser.NewIterator(rn.itemsStep1)
   332  
   333  	// the parser is guaranteed to return items in proper order or fail, so …
   334  	// … it's safe to keep some "global" state
   335  	var ordinal int
   336  
   337  Loop:
   338  	for {
   339  		it := iter.Next()
   340  
   341  		switch {
   342  		case it.Type == pageparser.TypeIgnore:
   343  		case it.IsFrontMatter():
   344  			// Ignore.
   345  		case it.Type == pageparser.TypeLeadSummaryDivider:
   346  			posBody := -1
   347  			f := func(item pageparser.Item) bool {
   348  				if posBody == -1 && !item.IsDone() {
   349  					posBody = item.Pos()
   350  				}
   351  
   352  				if item.IsNonWhitespace(source) {
   353  					rn.summaryTruncated = true
   354  
   355  					// Done
   356  					return false
   357  				}
   358  				return true
   359  			}
   360  			iter.PeekWalk(f)
   361  
   362  			rn.hasSummaryDivider = true
   363  
   364  			// The content may be rendered by Goldmark or similar,
   365  			// and we need to track the summary.
   366  			rn.AddReplacement(internalSummaryDividerPre, it)
   367  
   368  		// Handle shortcode
   369  		case it.IsLeftShortcodeDelim():
   370  			// let extractShortcode handle left delim (will do so recursively)
   371  			iter.Backup()
   372  
   373  			currShortcode, err := s.extractShortcode(ordinal, 0, source, iter)
   374  			if err != nil {
   375  				return fail(err, it)
   376  			}
   377  
   378  			currShortcode.pos = it.Pos()
   379  			currShortcode.length = iter.Current().Pos() - it.Pos()
   380  			if currShortcode.placeholder == "" {
   381  				currShortcode.placeholder = createShortcodePlaceholder("s", rn.pid, currShortcode.ordinal)
   382  			}
   383  
   384  			if currShortcode.name != "" {
   385  				s.addName(currShortcode.name)
   386  			}
   387  
   388  			if currShortcode.params == nil {
   389  				var s []string
   390  				currShortcode.params = s
   391  			}
   392  
   393  			currShortcode.placeholder = createShortcodePlaceholder("s", rn.pid, ordinal)
   394  			ordinal++
   395  			s.shortcodes = append(s.shortcodes, currShortcode)
   396  
   397  			rn.AddShortcode(currShortcode)
   398  
   399  		case it.IsEOF():
   400  			break Loop
   401  		case it.IsError():
   402  			return fail(it.Err, it)
   403  		default:
   404  			rn.AddBytes(it)
   405  		}
   406  	}
   407  
   408  	return nil
   409  }
   410  
   411  func (c *cachedContent) mustSource() []byte {
   412  	source, err := c.pi.contentSource(c)
   413  	if err != nil {
   414  		panic(err)
   415  	}
   416  	return source
   417  }
   418  
   419  func (c *contentParseInfo) contentSource(s resource.StaleInfo) ([]byte, error) {
   420  	key := c.sourceKey
   421  	v, err := c.h.cacheContentSource.GetOrCreate(key, func(string) (*resources.StaleValue[[]byte], error) {
   422  		b, err := c.readSourceAll()
   423  		if err != nil {
   424  			return nil, err
   425  		}
   426  
   427  		return &resources.StaleValue[[]byte]{
   428  			Value: b,
   429  			IsStaleFunc: func() bool {
   430  				return s.IsStale()
   431  			},
   432  		}, nil
   433  	})
   434  	if err != nil {
   435  		return nil, err
   436  	}
   437  
   438  	return v.Value, nil
   439  }
   440  
   441  func (c *contentParseInfo) readSourceAll() ([]byte, error) {
   442  	if c.openSource == nil {
   443  		return []byte{}, nil
   444  	}
   445  	r, err := c.openSource()
   446  	if err != nil {
   447  		return nil, err
   448  	}
   449  	defer r.Close()
   450  
   451  	return io.ReadAll(r)
   452  }
   453  
   454  type contentTableOfContents struct {
   455  	// For Goldmark we split Parse and Render.
   456  	astDoc any
   457  
   458  	tableOfContents     *tableofcontents.Fragments
   459  	tableOfContentsHTML template.HTML
   460  
   461  	// Temporary storage of placeholders mapped to their content.
   462  	// These are shortcodes etc. Some of these will need to be replaced
   463  	// after any markup is rendered, so they share a common prefix.
   464  	contentPlaceholders map[string]shortcodeRenderer
   465  
   466  	contentToRender []byte
   467  }
   468  
   469  type contentSummary struct {
   470  	content          template.HTML
   471  	summary          template.HTML
   472  	summaryTruncated bool
   473  }
   474  
   475  type contentPlainPlainWords struct {
   476  	plain      string
   477  	plainWords []string
   478  
   479  	summary          template.HTML
   480  	summaryTruncated bool
   481  
   482  	wordCount      int
   483  	fuzzyWordCount int
   484  	readingTime    int
   485  }
   486  
   487  func (c *cachedContent) contentRendered(ctx context.Context, cp *pageContentOutput) (contentSummary, error) {
   488  	ctx = tpl.Context.DependencyScope.Set(ctx, pageDependencyScopeGlobal)
   489  	key := c.pi.sourceKey + "/" + cp.po.f.Name
   490  	versionv := cp.contentRenderedVersion
   491  
   492  	v, err := c.pm.cacheContentRendered.GetOrCreate(key, func(string) (*resources.StaleValue[contentSummary], error) {
   493  		cp.po.p.s.Log.Trace(logg.StringFunc(func() string {
   494  			return fmt.Sprintln("contentRendered", key)
   495  		}))
   496  
   497  		cp.po.p.s.h.contentRenderCounter.Add(1)
   498  		cp.contentRendered = true
   499  		po := cp.po
   500  
   501  		ct, err := c.contentToC(ctx, cp)
   502  		if err != nil {
   503  			return nil, err
   504  		}
   505  
   506  		rs := &resources.StaleValue[contentSummary]{
   507  			IsStaleFunc: func() bool {
   508  				return c.IsStale() || cp.contentRenderedVersion != versionv
   509  			},
   510  		}
   511  
   512  		if len(c.pi.itemsStep2) == 0 {
   513  			// Nothing to do.
   514  			return rs, nil
   515  		}
   516  
   517  		var b []byte
   518  
   519  		if ct.astDoc != nil {
   520  			// The content is parsed, but not rendered.
   521  			r, ok, err := po.contentRenderer.RenderContent(ctx, ct.contentToRender, ct.astDoc)
   522  			if err != nil {
   523  				return nil, err
   524  			}
   525  			if !ok {
   526  				return nil, errors.New("invalid state: astDoc is set but RenderContent returned false")
   527  			}
   528  
   529  			b = r.Bytes()
   530  
   531  		} else {
   532  			// Copy the content to be rendered.
   533  			b = make([]byte, len(ct.contentToRender))
   534  			copy(b, ct.contentToRender)
   535  		}
   536  
   537  		// There are one or more replacement tokens to be replaced.
   538  		var hasShortcodeVariants bool
   539  		tokenHandler := func(ctx context.Context, token string) ([]byte, error) {
   540  			if token == tocShortcodePlaceholder {
   541  				return []byte(ct.tableOfContentsHTML), nil
   542  			}
   543  			renderer, found := ct.contentPlaceholders[token]
   544  			if found {
   545  				repl, more, err := renderer.renderShortcode(ctx)
   546  				if err != nil {
   547  					return nil, err
   548  				}
   549  				hasShortcodeVariants = hasShortcodeVariants || more
   550  				return repl, nil
   551  			}
   552  			// This should never happen.
   553  			panic(fmt.Errorf("unknown shortcode token %q (number of tokens: %d)", token, len(ct.contentPlaceholders)))
   554  		}
   555  
   556  		b, err = expandShortcodeTokens(ctx, b, tokenHandler)
   557  		if err != nil {
   558  			return nil, err
   559  		}
   560  		if hasShortcodeVariants {
   561  			cp.po.p.pageOutputTemplateVariationsState.Add(1)
   562  		}
   563  
   564  		var result contentSummary // hasVariants bool
   565  
   566  		if c.pi.hasSummaryDivider {
   567  			isHTML := cp.po.p.m.pageConfig.Markup == "html"
   568  			if isHTML {
   569  				// Use the summary sections as provided by the user.
   570  				i := bytes.Index(b, internalSummaryDividerPre)
   571  				result.summary = helpers.BytesToHTML(b[:i])
   572  				b = b[i+len(internalSummaryDividerPre):]
   573  
   574  			} else {
   575  				summary, content, err := splitUserDefinedSummaryAndContent(cp.po.p.m.pageConfig.Markup, b)
   576  				if err != nil {
   577  					cp.po.p.s.Log.Errorf("Failed to set user defined summary for page %q: %s", cp.po.p.pathOrTitle(), err)
   578  				} else {
   579  					b = content
   580  					result.summary = helpers.BytesToHTML(summary)
   581  				}
   582  			}
   583  			result.summaryTruncated = c.pi.summaryTruncated
   584  		}
   585  		result.content = helpers.BytesToHTML(b)
   586  		rs.Value = result
   587  
   588  		return rs, nil
   589  	})
   590  	if err != nil {
   591  		return contentSummary{}, cp.po.p.wrapError(err)
   592  	}
   593  
   594  	return v.Value, nil
   595  }
   596  
   597  func (c *cachedContent) mustContentToC(ctx context.Context, cp *pageContentOutput) contentTableOfContents {
   598  	ct, err := c.contentToC(ctx, cp)
   599  	if err != nil {
   600  		panic(err)
   601  	}
   602  	return ct
   603  }
   604  
   605  var setGetContentCallbackInContext = hcontext.NewContextDispatcher[func(*pageContentOutput, contentTableOfContents)]("contentCallback")
   606  
   607  func (c *cachedContent) contentToC(ctx context.Context, cp *pageContentOutput) (contentTableOfContents, error) {
   608  	key := c.pi.sourceKey + "/" + cp.po.f.Name
   609  	versionv := cp.contentRenderedVersion
   610  
   611  	v, err := c.pm.contentTableOfContents.GetOrCreate(key, func(string) (*resources.StaleValue[contentTableOfContents], error) {
   612  		source, err := c.pi.contentSource(c)
   613  		if err != nil {
   614  			return nil, err
   615  		}
   616  
   617  		var ct contentTableOfContents
   618  		if err := cp.initRenderHooks(); err != nil {
   619  			return nil, err
   620  		}
   621  		f := cp.po.f
   622  		po := cp.po
   623  		p := po.p
   624  		ct.contentPlaceholders, err = c.shortcodeState.prepareShortcodesForPage(ctx, p, f, false)
   625  		if err != nil {
   626  			return nil, err
   627  		}
   628  
   629  		// Callback called from above (e.g. in .RenderString)
   630  		ctxCallback := func(cp2 *pageContentOutput, ct2 contentTableOfContents) {
   631  			// Merge content placeholders
   632  			for k, v := range ct2.contentPlaceholders {
   633  				ct.contentPlaceholders[k] = v
   634  			}
   635  
   636  			if p.s.conf.Internal.Watch {
   637  				for _, s := range cp2.po.p.m.content.shortcodeState.shortcodes {
   638  					for _, templ := range s.templs {
   639  						cp.trackDependency(templ.(identity.IdentityProvider))
   640  					}
   641  				}
   642  			}
   643  
   644  			// Transfer shortcode names so HasShortcode works for shortcodes from included pages.
   645  			cp.po.p.m.content.shortcodeState.transferNames(cp2.po.p.m.content.shortcodeState)
   646  			if cp2.po.p.pageOutputTemplateVariationsState.Load() > 0 {
   647  				cp.po.p.pageOutputTemplateVariationsState.Add(1)
   648  			}
   649  		}
   650  
   651  		ctx = setGetContentCallbackInContext.Set(ctx, ctxCallback)
   652  
   653  		var hasVariants bool
   654  		ct.contentToRender, hasVariants, err = c.pi.contentToRender(ctx, source, ct.contentPlaceholders)
   655  		if err != nil {
   656  			return nil, err
   657  		}
   658  
   659  		if hasVariants {
   660  			p.pageOutputTemplateVariationsState.Add(1)
   661  		}
   662  
   663  		isHTML := cp.po.p.m.pageConfig.Markup == "html"
   664  
   665  		if !isHTML {
   666  			createAndSetToC := func(tocProvider converter.TableOfContentsProvider) {
   667  				cfg := p.s.ContentSpec.Converters.GetMarkupConfig()
   668  				ct.tableOfContents = tocProvider.TableOfContents()
   669  				ct.tableOfContentsHTML = template.HTML(
   670  					ct.tableOfContents.ToHTML(
   671  						cfg.TableOfContents.StartLevel,
   672  						cfg.TableOfContents.EndLevel,
   673  						cfg.TableOfContents.Ordered,
   674  					),
   675  				)
   676  			}
   677  
   678  			// If the converter supports doing the parsing separately, we do that.
   679  			parseResult, ok, err := po.contentRenderer.ParseContent(ctx, ct.contentToRender)
   680  			if err != nil {
   681  				return nil, err
   682  			}
   683  			if ok {
   684  				// This is Goldmark.
   685  				// Store away the parse result for later use.
   686  				createAndSetToC(parseResult)
   687  
   688  				ct.astDoc = parseResult.Doc()
   689  
   690  			} else {
   691  
   692  				// This is Asciidoctor etc.
   693  				r, err := po.contentRenderer.ParseAndRenderContent(ctx, ct.contentToRender, true)
   694  				if err != nil {
   695  					return nil, err
   696  				}
   697  
   698  				ct.contentToRender = r.Bytes()
   699  
   700  				if tocProvider, ok := r.(converter.TableOfContentsProvider); ok {
   701  					createAndSetToC(tocProvider)
   702  				} else {
   703  					tmpContent, tmpTableOfContents := helpers.ExtractTOC(ct.contentToRender)
   704  					ct.tableOfContentsHTML = helpers.BytesToHTML(tmpTableOfContents)
   705  					ct.tableOfContents = tableofcontents.Empty
   706  					ct.contentToRender = tmpContent
   707  				}
   708  			}
   709  		}
   710  
   711  		return &resources.StaleValue[contentTableOfContents]{
   712  			Value: ct,
   713  			IsStaleFunc: func() bool {
   714  				return c.IsStale() || cp.contentRenderedVersion != versionv
   715  			},
   716  		}, nil
   717  	})
   718  	if err != nil {
   719  		return contentTableOfContents{}, err
   720  	}
   721  
   722  	return v.Value, nil
   723  }
   724  
   725  func (c *cachedContent) contentPlain(ctx context.Context, cp *pageContentOutput) (contentPlainPlainWords, error) {
   726  	key := c.pi.sourceKey + "/" + cp.po.f.Name
   727  
   728  	versionv := cp.contentRenderedVersion
   729  
   730  	v, err := c.pm.cacheContentPlain.GetOrCreateWitTimeout(key, cp.po.p.s.Conf.Timeout(), func(string) (*resources.StaleValue[contentPlainPlainWords], error) {
   731  		var result contentPlainPlainWords
   732  		rs := &resources.StaleValue[contentPlainPlainWords]{
   733  			IsStaleFunc: func() bool {
   734  				return c.IsStale() || cp.contentRenderedVersion != versionv
   735  			},
   736  		}
   737  
   738  		rendered, err := c.contentRendered(ctx, cp)
   739  		if err != nil {
   740  			return nil, err
   741  		}
   742  
   743  		result.plain = tpl.StripHTML(string(rendered.content))
   744  		result.plainWords = strings.Fields(result.plain)
   745  
   746  		isCJKLanguage := cp.po.p.m.pageConfig.IsCJKLanguage
   747  
   748  		if isCJKLanguage {
   749  			result.wordCount = 0
   750  			for _, word := range result.plainWords {
   751  				runeCount := utf8.RuneCountInString(word)
   752  				if len(word) == runeCount {
   753  					result.wordCount++
   754  				} else {
   755  					result.wordCount += runeCount
   756  				}
   757  			}
   758  		} else {
   759  			result.wordCount = helpers.TotalWords(result.plain)
   760  		}
   761  
   762  		// TODO(bep) is set in a test. Fix that.
   763  		if result.fuzzyWordCount == 0 {
   764  			result.fuzzyWordCount = (result.wordCount + 100) / 100 * 100
   765  		}
   766  
   767  		if isCJKLanguage {
   768  			result.readingTime = (result.wordCount + 500) / 501
   769  		} else {
   770  			result.readingTime = (result.wordCount + 212) / 213
   771  		}
   772  
   773  		if rendered.summary != "" {
   774  			result.summary = rendered.summary
   775  			result.summaryTruncated = rendered.summaryTruncated
   776  		} else if cp.po.p.m.pageConfig.Summary != "" {
   777  			b, err := cp.po.contentRenderer.ParseAndRenderContent(ctx, []byte(cp.po.p.m.pageConfig.Summary), false)
   778  			if err != nil {
   779  				return nil, err
   780  			}
   781  			html := cp.po.p.s.ContentSpec.TrimShortHTML(b.Bytes())
   782  			result.summary = helpers.BytesToHTML(html)
   783  		} else {
   784  			var summary string
   785  			var truncated bool
   786  			if isCJKLanguage {
   787  				summary, truncated = cp.po.p.s.ContentSpec.TruncateWordsByRune(result.plainWords)
   788  			} else {
   789  				summary, truncated = cp.po.p.s.ContentSpec.TruncateWordsToWholeSentence(result.plain)
   790  			}
   791  			result.summary = template.HTML(summary)
   792  			result.summaryTruncated = truncated
   793  		}
   794  
   795  		rs.Value = result
   796  
   797  		return rs, nil
   798  	})
   799  	if err != nil {
   800  		if herrors.IsTimeoutError(err) {
   801  			err = fmt.Errorf("timed out rendering the page content. You may have a circular loop in a shortcode, or your site may have resources that take longer to build than the `timeout` limit in your Hugo config file: %w", err)
   802  		}
   803  		return contentPlainPlainWords{}, err
   804  	}
   805  	return v.Value, nil
   806  }