Repository: gohugoio/hugo (language: go)

Issue:
A way to access content after <!--more--> marker
Currently one can get either the summary (`.Summary`) or all the content (`.Content`), but there's no way to access only the content after the <!--more--> marker, through a variable like `.MoreContent`, for example. In my case the summary and the rest are completely separate entities.



Discussion / hints:
Hi @opennota,
Is there a reason why you closed this issue before it was fixed?  :-)

@anthonyfok I don't need it anymore, and it doesn't look like anyone else is interested.

@opennota, actually I would very much like this feature. I missed #716 and #718 in the first place, and I am glad that I've seen them now.

Let's reopen this issue then!  ;-)

+1

It would be nice to have both .Summary (only the summary) and .Footnotes (only the footnotes generated by blackfriday) coupled with .Body( .Content minus .Summary, minus .Footnotes) too.

@Dr-Terrible for the BF related issues, see https://github.com/russross/blackfriday/issues/166

@bep Thank you, your idea about separate entities makes more sense. I didn't think about the ToC either.

+1

I would like this.  In fact the more that we can define sections in our content the better.

If anyone is looking for a quick workaround for now you can do this:

{{ $body := replace .Content .Summary "" }}
{{ $body | safeHTML }}


This issue has been automatically marked as stale because it has not been commented on for at least four months.

The resources of the Hugo team are limited, and so we are asking for your help.

If this is a **bug** and you can still reproduce this error on the <code>master</code> branch, please reply with all of the information you have about it in order to keep the issue open.

If this is a **feature request**, and you feel that it is still valuable, please open a proposal at https://discuss.gohugo.io/.

This issue will automatically be closed in four months if no further activity occurs. Thank you for all your contributions.


**Note/Update:** This issue is marked as stale, and I may have said something earlier about "opening a thread on the discussion forum". Please don't.

If this is a **bug** and you can still reproduce this error on the latest <code>release</code> or the <code>master</code> branch, please reply with all of the information you have about it in order to keep the issue open.

If this is a **feature request**, and you feel that it is still relevant and valuable, please tell us why.

I like an idea. :+1: What can I do for it?
Is there an interest in this?

My PR didn't get any attention and eventually became stale. And yet issue remains open.
I could prepare a PR again, but would like to know first if project maintainers are interested in this feature.
+1 for this enhancement.

Two potential uses for this that immediately jump to mind (I'm longing for the first one)

1) You have a 'read mode' link to expand and show the remainder of the content (potentially useful on a section page where you have some descriptive content (drawn from the relevant _index.md) displayed prior to the list of pages within that section. Especially on mobile you may wish to hide the full content.

2) On a page (err..) page where you want to use 'read more' to trigger some kind of analytics event that the user has engaged with the content.
I know this is probably considered "just a workaround", but with [findRE](https://gohugo.io/functions/findre/) you should be able to match "everything after `<-- more -->` for these uncommon use cases. 
I like this idea 👍  because I always put ToC after Summary

Fix patch:
diff --git a/common/hugo/hugo.go b/common/hugo/hugo.go
index 0589ac9a36d..09ab8717f9a 100644
--- a/common/hugo/hugo.go
+++ b/common/hugo/hugo.go
@@ -14,6 +14,7 @@
 package hugo
 
 import (
+	"context"
 	"fmt"
 	"html/template"
 	"os"
@@ -29,6 +30,7 @@ import (
 	"github.com/mitchellh/mapstructure"
 
 	"github.com/bep/godartsass/v2"
+	"github.com/gohugoio/hugo/common/hcontext"
 	"github.com/gohugoio/hugo/common/hexec"
 	"github.com/gohugoio/hugo/common/loggers"
 	"github.com/gohugoio/hugo/hugofs/files"
@@ -69,6 +71,9 @@ type HugoInfo struct {
 
 	conf ConfigProvider
 	deps []*Dependency
+
+	// Context gives access to some of the context scoped variables.
+	Context Context
 }
 
 // Version returns the current version as a comparable version string.
@@ -127,6 +132,26 @@ func (i HugoInfo) IsMultilingual() bool {
 	return i.conf.IsMultilingual()
 }
 
+type contextKey string
+
+var markupScope = hcontext.NewContextDispatcher[string](contextKey("markupScope"))
+
+type Context struct{}
+
+func (c Context) MarkupScope(ctx context.Context) string {
+	return GetMarkupScope(ctx)
+}
+
+// SetMarkupScope sets the markup scope in the context.
+func SetMarkupScope(ctx context.Context, s string) context.Context {
+	return markupScope.Set(ctx, s)
+}
+
+// GetMarkupScope gets the markup scope from the context.
+func GetMarkupScope(ctx context.Context) string {
+	return markupScope.Get(ctx)
+}
+
 // ConfigProvider represents the config options that are relevant for HugoInfo.
 type ConfigProvider interface {
 	Environment() string
diff --git a/common/paths/pathparser.go b/common/paths/pathparser.go
index 5fa798fb057..94329fe7a67 100644
--- a/common/paths/pathparser.go
+++ b/common/paths/pathparser.go
@@ -153,7 +153,7 @@ func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) {
 				} else {
 					high = len(p.s)
 				}
-				id := types.LowHigh{Low: i + 1, High: high}
+				id := types.LowHigh[string]{Low: i + 1, High: high}
 				if len(p.identifiers) == 0 {
 					p.identifiers = append(p.identifiers, id)
 				} else if len(p.identifiers) == 1 {
@@ -260,7 +260,7 @@ type Path struct {
 	component  string
 	bundleType PathType
 
-	identifiers []types.LowHigh
+	identifiers []types.LowHigh[string]
 
 	posIdentifierLanguage int
 	disabled              bool
diff --git a/common/types/types.go b/common/types/types.go
index 322dfe592b0..d32391a88b7 100644
--- a/common/types/types.go
+++ b/common/types/types.go
@@ -107,12 +107,20 @@ func Unwrapv(v any) any {
 	return v
 }
 
-// LowHigh is typically used to represent a slice boundary.
-type LowHigh struct {
+// LowHigh represents a byte or slice boundary.
+type LowHigh[S ~[]byte | string] struct {
 	Low  int
 	High int
 }
 
+func (l LowHigh[S]) IsZero() bool {
+	return l.Low < 0 || (l.Low == 0 && l.High == 0)
+}
+
+func (l LowHigh[S]) Value(source S) S {
+	return source[l.Low:l.High]
+}
+
 // This is only used for debugging purposes.
 var InvocationCounter atomic.Int64
 
diff --git a/helpers/content.go b/helpers/content.go
index 49283d52631..9d74a3d3191 100644
--- a/helpers/content.go
+++ b/helpers/content.go
@@ -22,7 +22,6 @@ import (
 	"html/template"
 	"strings"
 	"unicode"
-	"unicode/utf8"
 
 	"github.com/gohugoio/hugo/common/hexec"
 	"github.com/gohugoio/hugo/common/loggers"
@@ -165,75 +164,6 @@ func TotalWords(s string) int {
 	return n
 }
 
-// TruncateWordsByRune truncates words by runes.
-func (c *ContentSpec) TruncateWordsByRune(in []string) (string, bool) {
-	words := make([]string, len(in))
-	copy(words, in)
-
-	count := 0
-	for index, word := range words {
-		if count >= c.Cfg.SummaryLength() {
-			return strings.Join(words[:index], " "), true
-		}
-		runeCount := utf8.RuneCountInString(word)
-		if len(word) == runeCount {
-			count++
-		} else if count+runeCount < c.Cfg.SummaryLength() {
-			count += runeCount
-		} else {
-			for ri := range word {
-				if count >= c.Cfg.SummaryLength() {
-					truncatedWords := append(words[:index], word[:ri])
-					return strings.Join(truncatedWords, " "), true
-				}
-				count++
-			}
-		}
-	}
-
-	return strings.Join(words, " "), false
-}
-
-// TruncateWordsToWholeSentence takes content and truncates to whole sentence
-// limited by max number of words. It also returns whether it is truncated.
-func (c *ContentSpec) TruncateWordsToWholeSentence(s string) (string, bool) {
-	var (
-		wordCount     = 0
-		lastWordIndex = -1
-	)
-
-	for i, r := range s {
-		if unicode.IsSpace(r) {
-			wordCount++
-			lastWordIndex = i
-
-			if wordCount >= c.Cfg.SummaryLength() {
-				break
-			}
-
-		}
-	}
-
-	if lastWordIndex == -1 {
-		return s, false
-	}
-
-	endIndex := -1
-
-	for j, r := range s[lastWordIndex:] {
-		if isEndOfSentence(r) {
-			endIndex = j + lastWordIndex + utf8.RuneLen(r)
-			break
-		}
-	}
-
-	if endIndex == -1 {
-		return s, false
-	}
-
-	return strings.TrimSpace(s[:endIndex]), endIndex < len(s)
-}
-
 // TrimShortHTML removes the outer tags from HTML input where (a) the opening
 // tag is present only once with the input, and (b) the opening and closing
 // tags wrap the input after white space removal.
@@ -256,7 +186,3 @@ func (c *ContentSpec) TrimShortHTML(input []byte, markup string) []byte {
 	}
 	return input
 }
-
-func isEndOfSentence(r rune) bool {
-	return r == '.' || r == '?' || r == '!' || r == '"' || r == '\n'
-}
diff --git a/hugolib/page.go b/hugolib/page.go
index 20751c57cee..7525ab67267 100644
--- a/hugolib/page.go
+++ b/hugolib/page.go
@@ -61,6 +61,7 @@ var (
 	pageTypesProvider = resource.NewResourceTypesProvider(media.Builtin.OctetType, pageResourceType)
 	nopPageOutput     = &pageOutput{
 		pagePerOutputProviders: nopPagePerOutput,
+		MarkupProvider:         page.NopPage,
 		ContentProvider:        page.NopPage,
 	}
 )
@@ -213,11 +214,8 @@ func (p *pageHeadingsFiltered) page() page.Page {
 
 // For internal use by the related content feature.
 func (p *pageState) ApplyFilterToHeadings(ctx context.Context, fn func(*tableofcontents.Heading) bool) related.Document {
-	r, err := p.m.content.contentToC(ctx, p.pageOutput.pco)
-	if err != nil {
-		panic(err)
-	}
-	headings := r.tableOfContents.Headings.FilterBy(fn)
+	fragments := p.pageOutput.pco.c().Fragments(ctx)
+	headings := fragments.Headings.FilterBy(fn)
 	return &pageHeadingsFiltered{
 		pageState: p,
 		headings:  headings,
@@ -719,6 +717,7 @@ func (p *pageState) shiftToOutputFormat(isRenderingSite bool, idx int) error {
 			})
 			p.pageOutput.contentRenderer = lcp
 			p.pageOutput.ContentProvider = lcp
+			p.pageOutput.MarkupProvider = lcp
 			p.pageOutput.PageRenderProvider = lcp
 			p.pageOutput.TableOfContentsProvider = lcp
 		}
diff --git a/hugolib/page__content.go b/hugolib/page__content.go
index 1119a8a95bb..30caebed0d9 100644
--- a/hugolib/page__content.go
+++ b/hugolib/page__content.go
@@ -14,7 +14,6 @@
 package hugolib
 
 import (
-	"bytes"
 	"context"
 	"errors"
 	"fmt"
@@ -29,15 +28,23 @@ import (
 	"github.com/gohugoio/hugo/common/hcontext"
 	"github.com/gohugoio/hugo/common/herrors"
 	"github.com/gohugoio/hugo/common/hugio"
+	"github.com/gohugoio/hugo/common/hugo"
+	"github.com/gohugoio/hugo/common/maps"
+	"github.com/gohugoio/hugo/common/types/hstring"
 	"github.com/gohugoio/hugo/helpers"
 	"github.com/gohugoio/hugo/identity"
+	"github.com/gohugoio/hugo/markup"
 	"github.com/gohugoio/hugo/markup/converter"
+	"github.com/gohugoio/hugo/markup/goldmark/hugocontext"
 	"github.com/gohugoio/hugo/markup/tableofcontents"
 	"github.com/gohugoio/hugo/parser/metadecoders"
 	"github.com/gohugoio/hugo/parser/pageparser"
 	"github.com/gohugoio/hugo/resources"
+	"github.com/gohugoio/hugo/resources/page"
 	"github.com/gohugoio/hugo/resources/resource"
 	"github.com/gohugoio/hugo/tpl"
+	"github.com/mitchellh/mapstructure"
+	"github.com/spf13/cast"
 )
 
 const (
@@ -45,8 +52,8 @@ const (
 )
 
 var (
-	internalSummaryDividerBaseBytes = []byte(internalSummaryDividerBase)
-	internalSummaryDividerPre       = []byte("\n\n" + internalSummaryDividerBase + "\n\n")
+	internalSummaryDividerPreString = "\n\n" + internalSummaryDividerBase + "\n\n"
+	internalSummaryDividerPre       = []byte(internalSummaryDividerPreString)
 )
 
 type pageContentReplacement struct {
@@ -130,6 +137,7 @@ func (m *pageMeta) newCachedContent(h *HugoSites, pi *contentParseInfo) (*cached
 		shortcodeState: newShortcodeHandler(filename, m.s),
 		pi:             pi,
 		enableEmoji:    m.s.conf.EnableEmoji,
+		scopes:         maps.NewCache[string, *cachedContentScope](),
 	}
 
 	source, err := c.pi.contentSource(m)
@@ -155,6 +163,20 @@ type cachedContent struct {
 	pi *contentParseInfo
 
 	enableEmoji bool
+
+	scopes *maps.Cache[string, *cachedContentScope]
+}
+
+func (c *cachedContent) getOrCreateScope(scope string, pco *pageContentOutput) *cachedContentScope {
+	key := scope + pco.po.f.Name
+	cs, _ := c.scopes.GetOrCreate(key, func() (*cachedContentScope, error) {
+		return &cachedContentScope{
+			cachedContent: c,
+			pco:           pco,
+			scope:         scope,
+		}, nil
+	})
+	return cs
 }
 
 type contentParseInfo struct {
@@ -171,9 +193,6 @@ type contentParseInfo struct {
 	// Whether the parsed content contains a summary separator.
 	hasSummaryDivider bool
 
-	// Whether there are more content after the summary divider.
-	summaryTruncated bool
-
 	// Returns the position in bytes after any front matter.
 	posMainContent int
 
@@ -368,8 +387,6 @@ Loop:
 				}
 
 				if item.IsNonWhitespace(source) {
-					rn.summaryTruncated = true
-
 					// Done
 					return false
 				}
@@ -487,26 +504,28 @@ type contentTableOfContents struct {
 }
 
 type contentSummary struct {
-	content          template.HTML
-	summary          template.HTML
-	summaryTruncated bool
+	content               string
+	contentWithoutSummary template.HTML
+	summary               page.Summary
 }
 
 type contentPlainPlainWords struct {
 	plain      string
 	plainWords []string
 
-	summary          template.HTML
-	summaryTruncated bool
-
 	wordCount      int
 	fuzzyWordCount int
 	readingTime    int
 }
 
-func (c *cachedContent) contentRendered(ctx context.Context, cp *pageContentOutput) (contentSummary, error) {
+func (c *cachedContentScope) keyScope(ctx context.Context) string {
+	return hugo.GetMarkupScope(ctx) + c.pco.po.f.Name
+}
+
+func (c *cachedContentScope) contentRendered(ctx context.Context) (contentSummary, error) {
+	cp := c.pco
 	ctx = tpl.Context.DependencyScope.Set(ctx, pageDependencyScopeGlobal)
-	key := c.pi.sourceKey + "/" + cp.po.f.Name
+	key := c.pi.sourceKey + "/" + c.keyScope(ctx)
 	versionv := c.version(cp)
 
 	v, err := c.pm.cacheContentRendered.GetOrCreate(key, func(string) (*resources.StaleValue[contentSummary], error) {
@@ -515,97 +534,121 @@ func (c *cachedContent) contentRendered(ctx context.Context, cp *pageContentOutp
 		}))
 
 		cp.po.p.s.h.contentRenderCounter.Add(1)
-		cp.contentRendered = true
+		cp.contentRendered.Store(true)
 		po := cp.po
 
-		ct, err := c.contentToC(ctx, cp)
+		ct, err := c.contentToC(ctx)
 		if err != nil {
 			return nil, err
 		}
 
-		rs := &resources.StaleValue[contentSummary]{
-			StaleVersionFunc: func() uint32 {
-				return c.version(cp) - versionv
-			},
-		}
+		rs, err := func() (*resources.StaleValue[contentSummary], error) {
+			rs := &resources.StaleValue[contentSummary]{
+				StaleVersionFunc: func() uint32 {
+					return c.version(cp) - versionv
+				},
+			}
 
-		if len(c.pi.itemsStep2) == 0 {
-			// Nothing to do.
-			return rs, nil
-		}
+			if len(c.pi.itemsStep2) == 0 {
+				// Nothing to do.
+				return rs, nil
+			}
 
-		var b []byte
+			var b []byte
 
-		if ct.astDoc != nil {
-			// The content is parsed, but not rendered.
-			r, ok, err := po.contentRenderer.RenderContent(ctx, ct.contentToRender, ct.astDoc)
-			if err != nil {
-				return nil, err
-			}
+			if ct.astDoc != nil {
+				// The content is parsed, but not rendered.
+				r, ok, err := po.contentRenderer.RenderContent(ctx, ct.contentToRender, ct.astDoc)
+				if err != nil {
+					return nil, err
+				}
 
-			if !ok {
-				return nil, errors.New("invalid state: astDoc is set but RenderContent returned false")
+				if !ok {
+					return nil, errors.New("invalid state: astDoc is set but RenderContent returned false")
+				}
+
+				b = r.Bytes()
+
+			} else {
+				// Copy the content to be rendered.
+				b = make([]byte, len(ct.contentToRender))
+				copy(b, ct.contentToRender)
 			}
 
-			b = r.Bytes()
+			// There are one or more replacement tokens to be replaced.
+			var hasShortcodeVariants bool
+			tokenHandler := func(ctx context.Context, token string) ([]byte, error) {
+				if token == tocShortcodePlaceholder {
+					return []byte(ct.tableOfContentsHTML), nil
+				}
+				renderer, found := ct.contentPlaceholders[token]
+				if found {
+					repl, more, err := renderer.renderShortcode(ctx)
+					if err != nil {
+						return nil, err
+					}
+					hasShortcodeVariants = hasShortcodeVariants || more
+					return repl, nil
+				}
+				// This should never happen.
+				panic(fmt.Errorf("unknown shortcode token %q (number of tokens: %d)", token, len(ct.contentPlaceholders)))
+			}
 
-		} else {
-			// Copy the content to be rendered.
-			b = make([]byte, len(ct.contentToRender))
-			copy(b, ct.contentToRender)
-		}
+			b, err = expandShortcodeTokens(ctx, b, tokenHandler)
+			if err != nil {
+				return nil, err
+			}
+			if hasShortcodeVariants {
+				cp.po.p.pageOutputTemplateVariationsState.Add(1)
+			}
 
-		// There are one or more replacement tokens to be replaced.
-		var hasShortcodeVariants bool
-		tokenHandler := func(ctx context.Context, token string) ([]byte, error) {
-			if token == tocShortcodePlaceholder {
-				return []byte(ct.tableOfContentsHTML), nil
+			var result contentSummary
+			if c.pi.hasSummaryDivider {
+				s := string(b)
+				summarized := page.ExtractSummaryFromHTMLWithDivider(cp.po.p.m.pageConfig.ContentMediaType, s, internalSummaryDividerBase)
+				result.summary = page.Summary{
+					Text:      template.HTML(summarized.Summary()),
+					Type:      page.SummaryTypeManual,
+					Truncated: summarized.Truncated(),
+				}
+				result.contentWithoutSummary = template.HTML(summarized.ContentWithoutSummary())
+				result.content = summarized.Content()
+			} else {
+				result.content = string(b)
 			}
-			renderer, found := ct.contentPlaceholders[token]
-			if found {
-				repl, more, err := renderer.renderShortcode(ctx)
-				if err != nil {
-					return nil, err
+
+			if !c.pi.hasSummaryDivider && cp.po.p.m.pageConfig.Summary == "" {
+				numWords := cp.po.p.s.conf.SummaryLength
+				isCJKLanguage := cp.po.p.m.pageConfig.IsCJKLanguage
+				summary := page.ExtractSummaryFromHTML(cp.po.p.m.pageConfig.ContentMediaType, string(result.content), numWords, isCJKLanguage)
+				result.summary = page.Summary{
+					Text:      template.HTML(summary.Summary()),
+					Type:      page.SummaryTypeAuto,
+					Truncated: summary.Truncated(),
 				}
-				hasShortcodeVariants = hasShortcodeVariants || more
-				return repl, nil
+				result.contentWithoutSummary = template.HTML(summary.ContentWithoutSummary())
 			}
-			// This should never happen.
-			panic(fmt.Errorf("unknown shortcode token %q (number of tokens: %d)", token, len(ct.contentPlaceholders)))
-		}
+			rs.Value = result
 
-		b, err = expandShortcodeTokens(ctx, b, tokenHandler)
+			return rs, nil
+		}()
 		if err != nil {
-			return nil, err
-		}
-		if hasShortcodeVariants {
-			cp.po.p.pageOutputTemplateVariationsState.Add(1)
+			return rs, cp.po.p.wrapError(err)
 		}
 
-		var result contentSummary // hasVariants bool
-
-		if c.pi.hasSummaryDivider {
-			if cp.po.p.m.pageConfig.ContentMediaType.IsHTML() {
-				// Use the summary sections as provided by the user.
-				i := bytes.Index(b, internalSummaryDividerPre)
-				result.summary = helpers.BytesToHTML(b[:i])
-				b = b[i+len(internalSummaryDividerPre):]
-
-			} else {
-				summary, content, err := splitUserDefinedSummaryAndContent(cp.po.p.m.pageConfig.Content.Markup, b)
-				if err != nil {
-					cp.po.p.s.Log.Errorf("Failed to set user defined summary for page %q: %s", cp.po.p.pathOrTitle(), err)
-				} else {
-					b = content
-					result.summary = helpers.BytesToHTML(summary)
-				}
+		if rs.Value.summary.IsZero() {
+			b, err := cp.po.contentRenderer.ParseAndRenderContent(ctx, []byte(cp.po.p.m.pageConfig.Summary), false)
+			if err != nil {
+				return nil, err
+			}
+			html := cp.po.p.s.ContentSpec.TrimShortHTML(b.Bytes(), cp.po.p.m.pageConfig.Content.Markup)
+			rs.Value.summary = page.Summary{
+				Text: helpers.BytesToHTML(html),
+				Type: page.SummaryTypeFrontMatter,
 			}
-			result.summaryTruncated = c.pi.summaryTruncated
 		}
-		result.content = helpers.BytesToHTML(b)
-		rs.Value = result
 
-		return rs, nil
+		return rs, err
 	})
 	if err != nil {
 		return contentSummary{}, cp.po.p.wrapError(err)
@@ -614,8 +657,8 @@ func (c *cachedContent) contentRendered(ctx context.Context, cp *pageContentOutp
 	return v.Value, nil
 }
 
-func (c *cachedContent) mustContentToC(ctx context.Context, cp *pageContentOutput) contentTableOfContents {
-	ct, err := c.contentToC(ctx, cp)
+func (c *cachedContentScope) mustContentToC(ctx context.Context) contentTableOfContents {
+	ct, err := c.contentToC(ctx)
 	if err != nil {
 		panic(err)
 	}
@@ -624,8 +667,9 @@ func (c *cachedContent) mustContentToC(ctx context.Context, cp *pageContentOutpu
 
 var setGetContentCallbackInContext = hcontext.NewContextDispatcher[func(*pageContentOutput, contentTableOfContents)]("contentCallback")
 
-func (c *cachedContent) contentToC(ctx context.Context, cp *pageContentOutput) (contentTableOfContents, error) {
-	key := c.pi.sourceKey + "/" + cp.po.f.Name
+func (c *cachedContentScope) contentToC(ctx context.Context) (contentTableOfContents, error) {
+	cp := c.pco
+	key := c.pi.sourceKey + "/" + c.keyScope(ctx)
 	versionv := c.version(cp)
 
 	v, err := c.pm.contentTableOfContents.GetOrCreate(key, func(string) (*resources.StaleValue[contentTableOfContents], error) {
@@ -648,7 +692,7 @@ func (c *cachedContent) contentToC(ctx context.Context, cp *pageContentOutput) (
 
 		// Callback called from below (e.g. in .RenderString)
 		ctxCallback := func(cp2 *pageContentOutput, ct2 contentTableOfContents) {
-			cp.otherOutputs[cp2.po.p.pid] = cp2
+			cp.otherOutputs.Set(cp2.po.p.pid, cp2)
 
 			// Merge content placeholders
 			for k, v := range ct2.contentPlaceholders {
@@ -749,8 +793,9 @@ func (c *cachedContent) version(cp *pageContentOutput) uint32 {
 	return c.StaleVersion() + cp.contentRenderedVersion
 }
 
-func (c *cachedContent) contentPlain(ctx context.Context, cp *pageContentOutput) (contentPlainPlainWords, error) {
-	key := c.pi.sourceKey + "/" + cp.po.f.Name
+func (c *cachedContentScope) contentPlain(ctx context.Context) (contentPlainPlainWords, error) {
+	cp := c.pco
+	key := c.pi.sourceKey + "/" + c.keyScope(ctx)
 
 	versionv := c.version(cp)
 
@@ -762,7 +807,7 @@ func (c *cachedContent) contentPlain(ctx context.Context, cp *pageContentOutput)
 			},
 		}
 
-		rendered, err := c.contentRendered(ctx, cp)
+		rendered, err := c.contentRendered(ctx)
 		if err != nil {
 			return nil, err
 		}
@@ -797,28 +842,6 @@ func (c *cachedContent) contentPlain(ctx context.Context, cp *pageContentOutput)
 			result.readingTime = (result.wordCount + 212) / 213
 		}
 
-		if c.pi.hasSummaryDivider || rendered.summary != "" {
-			result.summary = rendered.summary
-			result.summaryTruncated = rendered.summaryTruncated
-		} else if cp.po.p.m.pageConfig.Summary != "" {
-			b, err := cp.po.contentRenderer.ParseAndRenderContent(ctx, []byte(cp.po.p.m.pageConfig.Summary), false)
-			if err != nil {
-				return nil, err
-			}
-			html := cp.po.p.s.ContentSpec.TrimShortHTML(b.Bytes(), cp.po.p.m.pageConfig.Content.Markup)
-			result.summary = helpers.BytesToHTML(html)
-		} else {
-			var summary string
-			var truncated bool
-			if isCJKLanguage {
-				summary, truncated = cp.po.p.s.ContentSpec.TruncateWordsByRune(result.plainWords)
-			} else {
-				summary, truncated = cp.po.p.s.ContentSpec.TruncateWordsToWholeSentence(result.plain)
-			}
-			result.summary = template.HTML(summary)
-			result.summaryTruncated = truncated
-		}
-
 		rs.Value = result
 
 		return rs, nil
@@ -831,3 +854,332 @@ func (c *cachedContent) contentPlain(ctx context.Context, cp *pageContentOutput)
 	}
 	return v.Value, nil
 }
+
+type cachedContentScope struct {
+	*cachedContent
+	pco   *pageContentOutput
+	scope string
+}
+
+func (c *cachedContentScope) prepareContext(ctx context.Context) context.Context {
+	// The markup scope is recursive, so if already set to a non zero value, preserve that value.
+	if s := hugo.GetMarkupScope(ctx); s != "" || s == c.scope {
+		return ctx
+	}
+	return hugo.SetMarkupScope(ctx, c.scope)
+}
+
+func (c *cachedContentScope) Render(ctx context.Context) (page.Content, error) {
+	return c, nil
+}
+
+func (c *cachedContentScope) Content(ctx context.Context) (template.HTML, error) {
+	ctx = c.prepareContext(ctx)
+	cr, err := c.contentRendered(ctx)
+	if err != nil {
+		return "", err
+	}
+	return template.HTML(cr.content), nil
+}
+
+func (c *cachedContentScope) ContentWithoutSummary(ctx context.Context) (template.HTML, error) {
+	ctx = c.prepareContext(ctx)
+	cr, err := c.contentRendered(ctx)
+	if err != nil {
+		return "", err
+	}
+	return cr.contentWithoutSummary, nil
+}
+
+func (c *cachedContentScope) Summary(ctx context.Context) (page.Summary, error) {
+	ctx = c.prepareContext(ctx)
+	rendered, err := c.contentRendered(ctx)
+	return rendered.summary, err
+}
+
+func (c *cachedContentScope) RenderString(ctx context.Context, args ...any) (template.HTML, error) {
+	ctx = c.prepareContext(ctx)
+
+	if len(args) < 1 || len(args) > 2 {
+		return "", errors.New("want 1 or 2 arguments")
+	}
+
+	pco := c.pco
+
+	var contentToRender string
+	opts := defaultRenderStringOpts
+	sidx := 1
+
+	if len(args) == 1 {
+		sidx = 0
+	} else {
+		m, ok := args[0].(map[string]any)
+		if !ok {
+			return "", errors.New("first argument must be a map")
+		}
+
+		if err := mapstructure.WeakDecode(m, &opts); err != nil {
+			return "", fmt.Errorf("failed to decode options: %w", err)
+		}
+		if opts.Markup != "" {
+			opts.Markup = markup.ResolveMarkup(opts.Markup)
+		}
+	}
+
+	contentToRenderv := args[sidx]
+
+	if _, ok := contentToRenderv.(hstring.RenderedString); ok {
+		// This content is already rendered, this is potentially
+		// a infinite recursion.
+		return "", errors.New("text is already rendered, repeating it may cause infinite recursion")
+	}
+
+	var err error
+	contentToRender, err = cast.ToStringE(contentToRenderv)
+	if err != nil {
+		return "", err
+	}
+
+	if err = pco.initRenderHooks(); err != nil {
+		return "", err
+	}
+
+	conv := pco.po.p.getContentConverter()
+
+	if opts.Markup != "" && opts.Markup != pco.po.p.m.pageConfig.ContentMediaType.SubType {
+		var err error
+		conv, err = pco.po.p.m.newContentConverter(pco.po.p, opts.Markup)
+		if err != nil {
+			return "", pco.po.p.wrapError(err)
+		}
+	}
+
+	var rendered []byte
+
+	parseInfo := &contentParseInfo{
+		h:   pco.po.p.s.h,
+		pid: pco.po.p.pid,
+	}
+
+	if pageparser.HasShortcode(contentToRender) {
+		contentToRenderb := []byte(contentToRender)
+		// String contains a shortcode.
+		parseInfo.itemsStep1, err = pageparser.ParseBytes(contentToRenderb, pageparser.Config{
+			NoFrontMatter:    true,
+			NoSummaryDivider: true,
+		})
+		if err != nil {
+			return "", err
+		}
+
+		s := newShortcodeHandler(pco.po.p.pathOrTitle(), pco.po.p.s)
+		if err := parseInfo.mapItemsAfterFrontMatter(contentToRenderb, s); err != nil {
+			return "", err
+		}
+
+		placeholders, err := s.prepareShortcodesForPage(ctx, pco.po.p, pco.po.f, true)
+		if err != nil {
+			return "", err
+		}
+
+		contentToRender, hasVariants, err := parseInfo.contentToRender(ctx, contentToRenderb, placeholders)
+		if err != nil {
+			return "", err
+		}
+		if hasVariants {
+			pco.po.p.pageOutputTemplateVariationsState.Add(1)
+		}
+		b, err := pco.renderContentWithConverter(ctx, conv, contentToRender, false)
+		if err != nil {
+			return "", pco.po.p.wrapError(err)
+		}
+		rendered = b.Bytes()
+
+		if parseInfo.hasNonMarkdownShortcode {
+			var hasShortcodeVariants bool
+
+			tokenHandler := func(ctx context.Context, token string) ([]byte, error) {
+				if token == tocShortcodePlaceholder {
+					toc, err := c.contentToC(ctx)
+					if err != nil {
+						return nil, err
+					}
+					// The Page's TableOfContents was accessed in a shortcode.
+					return []byte(toc.tableOfContentsHTML), nil
+				}
+				renderer, found := placeholders[token]
+				if found {
+					repl, more, err := renderer.renderShortcode(ctx)
+					if err != nil {
+						return nil, err
+					}
+					hasShortcodeVariants = hasShortcodeVariants || more
+					return repl, nil
+				}
+				// This should not happen.
+				return nil, fmt.Errorf("unknown shortcode token %q", token)
+			}
+
+			rendered, err = expandShortcodeTokens(ctx, rendered, tokenHandler)
+			if err != nil {
+				return "", err
+			}
+			if hasShortcodeVariants {
+				pco.po.p.pageOutputTemplateVariationsState.Add(1)
+			}
+		}
+
+		// We need a consolidated view in $page.HasShortcode
+		pco.po.p.m.content.shortcodeState.transferNames(s)
+
+	} else {
+		c, err := pco.renderContentWithConverter(ctx, conv, []byte(contentToRender), false)
+		if err != nil {
+			return "", pco.po.p.wrapError(err)
+		}
+
+		rendered = c.Bytes()
+	}
+
+	if opts.Display == "inline" {
+		markup := pco.po.p.m.pageConfig.Content.Markup
+		if opts.Markup != "" {
+			markup = pco.po.p.s.ContentSpec.ResolveMarkup(opts.Markup)
+		}
+		rendered = pco.po.p.s.ContentSpec.TrimShortHTML(rendered, markup)
+	}
+
+	return template.HTML(string(rendered)), nil
+}
+
+func (c *cachedContentScope) RenderShortcodes(ctx context.Context) (template.HTML, error) {
+	ctx = c.prepareContext(ctx)
+
+	pco := c.pco
+	content := pco.po.p.m.content
+
+	source, err := content.pi.contentSource(content)
+	if err != nil {
+		return "", err
+	}
+	ct, err := c.contentToC(ctx)
+	if err != nil {
+		return "", err
+	}
+
+	var insertPlaceholders bool
+	var hasVariants bool
+	cb := setGetContentCallbackInContext.Get(ctx)
+	if cb != nil {
+		insertPlaceholders = true
+	}
+	cc := make([]byte, 0, len(source)+(len(source)/10))
+	for _, it := range content.pi.itemsStep2 {
+		switch v := it.(type) {
+		case pageparser.Item:
+			cc = append(cc, source[v.Pos():v.Pos()+len(v.Val(source))]...)
+		case pageContentReplacement:
+			// Ignore.
+		case *shortcode:
+			if !insertPlaceholders || !v.insertPlaceholder() {
+				// Insert the rendered shortcode.
+				renderedShortcode, found := ct.contentPlaceholders[v.placeholder]
+				if !found {
+					// This should never happen.
+					panic(fmt.Sprintf("rendered shortcode %q not found", v.placeholder))
+				}
+
+				b, more, err := renderedShortcode.renderShortcode(ctx)
+				if err != nil {
+					return "", fmt.Errorf("failed to render shortcode: %w", err)
+				}
+				hasVariants = hasVariants || more
+				cc = append(cc, []byte(b)...)
+
+			} else {
+				// Insert the placeholder so we can insert the content after
+				// markdown processing.
+				cc = append(cc, []byte(v.placeholder)...)
+			}
+		default:
+			panic(fmt.Sprintf("unknown item type %T", it))
+		}
+	}
+
+	if hasVariants {
+		pco.po.p.pageOutputTemplateVariationsState.Add(1)
+	}
+
+	if cb != nil {
+		cb(pco, ct)
+	}
+
+	if tpl.Context.IsInGoldmark.Get(ctx) {
+		// This content will be parsed and rendered by Goldmark.
+		// Wrap it in a special Hugo markup to assign the correct Page from
+		// the stack.
+		return template.HTML(hugocontext.Wrap(cc, pco.po.p.pid)), nil
+	}
+
+	return helpers.BytesToHTML(cc), nil
+}
+
+func (c *cachedContentScope) Plain(ctx context.Context) string {
+	ctx = c.prepareContext(ctx)
+	return c.mustContentPlain(ctx).plain
+}
+
+func (c *cachedContentScope) PlainWords(ctx context.Context) []string {
+	ctx = c.prepareContext(ctx)
+	return c.mustContentPlain(ctx).plainWords
+}
+
+func (c *cachedContentScope) WordCount(ctx context.Context) int {
+	ctx = c.prepareContext(ctx)
+	return c.mustContentPlain(ctx).wordCount
+}
+
+func (c *cachedContentScope) FuzzyWordCount(ctx context.Context) int {
+	ctx = c.prepareContext(ctx)
+	return c.mustContentPlain(ctx).fuzzyWordCount
+}
+
+func (c *cachedContentScope) ReadingTime(ctx context.Context) int {
+	ctx = c.prepareContext(ctx)
+	return c.mustContentPlain(ctx).readingTime
+}
+
+func (c *cachedContentScope) Len(ctx context.Context) int {
+	ctx = c.prepareContext(ctx)
+	return len(c.mustContentRendered(ctx).content)
+}
+
+func (c *cachedContentScope) Fragments(ctx context.Context) *tableofcontents.Fragments {
+	ctx = c.prepareContext(ctx)
+	toc := c.mustContentToC(ctx).tableOfContents
+	if toc == nil {
+		return nil
+	}
+	return toc
+}
+
+func (c *cachedContentScope) fragmentsHTML(ctx context.Context) template.HTML {
+	ctx = c.prepareContext(ctx)
+	return c.mustContentToC(ctx).tableOfContentsHTML
+}
+
+func (c *cachedContentScope) mustContentPlain(ctx context.Context) contentPlainPlainWords {
+	r, err := c.contentPlain(ctx)
+	if err != nil {
+		c.pco.fail(err)
+	}
+	return r
+}
+
+func (c *cachedContentScope) mustContentRendered(ctx context.Context) contentSummary {
+	r, err := c.contentRendered(ctx)
+	if err != nil {
+		c.pco.fail(err)
+	}
+	return r
+}
diff --git a/hugolib/page__meta.go b/hugolib/page__meta.go
index b23084a470a..56e26ecdbc9 100644
--- a/hugolib/page__meta.go
+++ b/hugolib/page__meta.go
@@ -821,7 +821,7 @@ func (p *pageMeta) newContentConverter(ps *pageState, markup string) (converter.
 			// This prevents infinite recursion in some cases.
 			return doc
 		}
-		if v, ok := ps.pageOutput.pco.otherOutputs[id]; ok {
+		if v, ok := ps.pageOutput.pco.otherOutputs.Get(id); ok {
 			return v.po.p
 		}
 		return nil
diff --git a/hugolib/page__output.go b/hugolib/page__output.go
index 2f4d6c205d4..b8086bb48b7 100644
--- a/hugolib/page__output.go
+++ b/hugolib/page__output.go
@@ -65,6 +65,7 @@ func newPageOutput(
 		p:                       ps,
 		f:                       f,
 		pagePerOutputProviders:  providers,
+		MarkupProvider:          page.NopPage,
 		ContentProvider:         page.NopPage,
 		PageRenderProvider:      page.NopPage,
 		TableOfContentsProvider: page.NopPage,
@@ -95,6 +96,7 @@ type pageOutput struct {
 	// output format.
 	contentRenderer page.ContentRenderer
 	pagePerOutputProviders
+	page.MarkupProvider
 	page.ContentProvider
 	page.PageRenderProvider
 	page.TableOfContentsProvider
@@ -119,7 +121,7 @@ func (po *pageOutput) isRendered() bool {
 	if po.renderState > 0 {
 		return true
 	}
-	if po.pco != nil && po.pco.contentRendered {
+	if po.pco != nil && po.pco.contentRendered.Load() {
 		return true
 	}
 	return false
@@ -139,6 +141,7 @@ func (p *pageOutput) setContentProvider(cp *pageContentOutput) {
 	}
 	p.contentRenderer = cp
 	p.ContentProvider = cp
+	p.MarkupProvider = cp
 	p.PageRenderProvider = cp
 	p.TableOfContentsProvider = cp
 	p.RenderShortcodesProvider = cp
diff --git a/hugolib/page__per_output.go b/hugolib/page__per_output.go
index 59cb574dfe0..f074e8db716 100644
--- a/hugolib/page__per_output.go
+++ b/hugolib/page__per_output.go
@@ -21,18 +21,14 @@ import (
 	"html/template"
 	"strings"
 	"sync"
+	"sync/atomic"
 
+	"github.com/gohugoio/hugo/common/maps"
 	"github.com/gohugoio/hugo/common/text"
-	"github.com/gohugoio/hugo/common/types/hstring"
 	"github.com/gohugoio/hugo/identity"
-	"github.com/gohugoio/hugo/markup"
-	"github.com/gohugoio/hugo/media"
-	"github.com/gohugoio/hugo/parser/pageparser"
-	"github.com/mitchellh/mapstructure"
 	"github.com/spf13/cast"
 
 	"github.com/gohugoio/hugo/markup/converter/hooks"
-	"github.com/gohugoio/hugo/markup/goldmark/hugocontext"
 	"github.com/gohugoio/hugo/markup/highlight/chromalexers"
 	"github.com/gohugoio/hugo/markup/tableofcontents"
 
@@ -41,7 +37,6 @@ import (
 	bp "github.com/gohugoio/hugo/bufferpool"
 	"github.com/gohugoio/hugo/tpl"
 
-	"github.com/gohugoio/hugo/helpers"
 	"github.com/gohugoio/hugo/output"
 	"github.com/gohugoio/hugo/resources/page"
 	"github.com/gohugoio/hugo/resources/resource"
@@ -73,7 +68,7 @@ func newPageContentOutput(po *pageOutput) (*pageContentOutput, error) {
 	cp := &pageContentOutput{
 		po:           po,
 		renderHooks:  &renderHooks{},
-		otherOutputs: make(map[uint64]*pageContentOutput),
+		otherOutputs: maps.NewCache[uint64, *pageContentOutput](),
 	}
 	return cp, nil
 }
@@ -89,10 +84,10 @@ type pageContentOutput struct {
 
 	// Other pages involved in rendering of this page,
 	// typically included with .RenderShortcodes.
-	otherOutputs map[uint64]*pageContentOutput
+	otherOutputs *maps.Cache[uint64, *pageContentOutput]
 
-	contentRenderedVersion uint32 // Incremented on reset.
-	contentRendered        bool   // Set on content render.
+	contentRenderedVersion uint32      // Incremented on reset.
+	contentRendered        atomic.Bool // Set on content render.
 
 	// Renders Markdown hooks.
 	renderHooks *renderHooks
@@ -107,109 +102,84 @@ func (pco *pageContentOutput) Reset() {
 		return
 	}
 	pco.contentRenderedVersion++
-	pco.contentRendered = false
+	pco.contentRendered.Store(false)
 	pco.renderHooks = &renderHooks{}
 }
 
-func (pco *pageContentOutput) Fragments(ctx context.Context) *tableofcontents.Fragments {
-	return pco.po.p.m.content.mustContentToC(ctx, pco).tableOfContents
-}
-
-func (pco *pageContentOutput) RenderShortcodes(ctx context.Context) (template.HTML, error) {
-	content := pco.po.p.m.content
-	source, err := content.pi.contentSource(content)
-	if err != nil {
-		return "", err
+func (pco *pageContentOutput) Render(ctx context.Context, layout ...string) (template.HTML, error) {
+	if len(layout) == 0 {
+		return "", errors.New("no layout given")
 	}
-	ct, err := content.contentToC(ctx, pco)
+	templ, found, err := pco.po.p.resolveTemplate(layout...)
 	if err != nil {
-		return "", err
+		return "", pco.po.p.wrapError(err)
 	}
 
-	var insertPlaceholders bool
-	var hasVariants bool
-	cb := setGetContentCallbackInContext.Get(ctx)
-	if cb != nil {
-		insertPlaceholders = true
+	if !found {
+		return "", nil
 	}
-	c := make([]byte, 0, len(source)+(len(source)/10))
-	for _, it := range content.pi.itemsStep2 {
-		switch v := it.(type) {
-		case pageparser.Item:
-			c = append(c, source[v.Pos():v.Pos()+len(v.Val(source))]...)
-		case pageContentReplacement:
-			// Ignore.
-		case *shortcode:
-			if !insertPlaceholders || !v.insertPlaceholder() {
-				// Insert the rendered shortcode.
-				renderedShortcode, found := ct.contentPlaceholders[v.placeholder]
-				if !found {
-					// This should never happen.
-					panic(fmt.Sprintf("rendered shortcode %q not found", v.placeholder))
-				}
 
-				b, more, err := renderedShortcode.renderShortcode(ctx)
-				if err != nil {
-					return "", fmt.Errorf("failed to render shortcode: %w", err)
-				}
-				hasVariants = hasVariants || more
-				c = append(c, []byte(b)...)
-
-			} else {
-				// Insert the placeholder so we can insert the content after
-				// markdown processing.
-				c = append(c, []byte(v.placeholder)...)
-			}
-		default:
-			panic(fmt.Sprintf("unknown item type %T", it))
-		}
+	// Make sure to send the *pageState and not the *pageContentOutput to the template.
+	res, err := executeToString(ctx, pco.po.p.s.Tmpl(), templ, pco.po.p)
+	if err != nil {
+		return "", pco.po.p.wrapError(fmt.Errorf("failed to execute template %s: %w", templ.Name(), err))
 	}
+	return template.HTML(res), nil
+}
 
-	if hasVariants {
-		pco.po.p.pageOutputTemplateVariationsState.Add(1)
-	}
+func (pco *pageContentOutput) Fragments(ctx context.Context) *tableofcontents.Fragments {
+	return pco.c().Fragments(ctx)
+}
 
-	if cb != nil {
-		cb(pco, ct)
-	}
+func (pco *pageContentOutput) RenderShortcodes(ctx context.Context) (template.HTML, error) {
+	return pco.c().RenderShortcodes(ctx)
+}
 
-	if tpl.Context.IsInGoldmark.Get(ctx) {
-		// This content will be parsed and rendered by Goldmark.
-		// Wrap it in a special Hugo markup to assign the correct Page from
-		// the stack.
-		return template.HTML(hugocontext.Wrap(c, pco.po.p.pid)), nil
+func (pco *pageContentOutput) Markup(opts ...any) page.Markup {
+	if len(opts) > 1 {
+		panic("too many arguments, expected 0 or 1")
+	}
+	var scope string
+	if len(opts) == 1 {
+		scope = cast.ToString(opts[0])
 	}
+	return pco.po.p.m.content.getOrCreateScope(scope, pco)
+}
 
-	return helpers.BytesToHTML(c), nil
+func (pco *pageContentOutput) c() page.Markup {
+	return pco.po.p.m.content.getOrCreateScope("", pco)
 }
 
 func (pco *pageContentOutput) Content(ctx context.Context) (any, error) {
-	r, err := pco.po.p.m.content.contentRendered(ctx, pco)
-	return r.content, err
+	r, err := pco.c().Render(ctx)
+	if err != nil {
+		return nil, err
+	}
+	return r.Content(ctx)
 }
 
-func (pco *pageContentOutput) TableOfContents(ctx context.Context) template.HTML {
-	return pco.po.p.m.content.mustContentToC(ctx, pco).tableOfContentsHTML
+func (pco *pageContentOutput) ContentWithoutSummary(ctx context.Context) (template.HTML, error) {
+	r, err := pco.c().Render(ctx)
+	if err != nil {
+		return "", err
+	}
+	return r.ContentWithoutSummary(ctx)
 }
 
-func (p *pageContentOutput) Len(ctx context.Context) int {
-	return len(p.mustContentRendered(ctx).content)
+func (pco *pageContentOutput) TableOfContents(ctx context.Context) template.HTML {
+	return pco.c().(*cachedContentScope).fragmentsHTML(ctx)
 }
 
-func (pco *pageContentOutput) mustContentRendered(ctx context.Context) contentSummary {
-	r, err := pco.po.p.m.content.contentRendered(ctx, pco)
-	if err != nil {
-		pco.fail(err)
-	}
-	return r
+func (pco *pageContentOutput) Len(ctx context.Context) int {
+	return pco.mustRender(ctx).Len(ctx)
 }
 
-func (pco *pageContentOutput) mustContentPlain(ctx context.Context) contentPlainPlainWords {
-	r, err := pco.po.p.m.content.contentPlain(ctx, pco)
+func (pco *pageContentOutput) mustRender(ctx context.Context) page.Content {
+	c, err := pco.c().Render(ctx)
 	if err != nil {
 		pco.fail(err)
 	}
-	return r
+	return c
 }
 
 func (pco *pageContentOutput) fail(err error) {
@@ -217,203 +187,43 @@ func (pco *pageContentOutput) fail(err error) {
 }
 
 func (pco *pageContentOutput) Plain(ctx context.Context) string {
-	return pco.mustContentPlain(ctx).plain
+	return pco.mustRender(ctx).Plain(ctx)
 }
 
 func (pco *pageContentOutput) PlainWords(ctx context.Context) []string {
-	return pco.mustContentPlain(ctx).plainWords
+	return pco.mustRender(ctx).PlainWords(ctx)
 }
 
 func (pco *pageContentOutput) ReadingTime(ctx context.Context) int {
-	return pco.mustContentPlain(ctx).readingTime
+	return pco.mustRender(ctx).ReadingTime(ctx)
 }
 
 func (pco *pageContentOutput) WordCount(ctx context.Context) int {
-	return pco.mustContentPlain(ctx).wordCount
+	return pco.mustRender(ctx).WordCount(ctx)
 }
 
 func (pco *pageContentOutput) FuzzyWordCount(ctx context.Context) int {
-	return pco.mustContentPlain(ctx).fuzzyWordCount
+	return pco.mustRender(ctx).FuzzyWordCount(ctx)
 }
 
 func (pco *pageContentOutput) Summary(ctx context.Context) template.HTML {
-	return pco.mustContentPlain(ctx).summary
-}
-
-func (pco *pageContentOutput) Truncated(ctx context.Context) bool {
-	return pco.mustContentPlain(ctx).summaryTruncated
-}
-
-func (pco *pageContentOutput) RenderString(ctx context.Context, args ...any) (template.HTML, error) {
-	if len(args) < 1 || len(args) > 2 {
-		return "", errors.New("want 1 or 2 arguments")
-	}
-
-	var contentToRender string
-	opts := defaultRenderStringOpts
-	sidx := 1
-
-	if len(args) == 1 {
-		sidx = 0
-	} else {
-		m, ok := args[0].(map[string]any)
-		if !ok {
-			return "", errors.New("first argument must be a map")
-		}
-
-		if err := mapstructure.WeakDecode(m, &opts); err != nil {
-			return "", fmt.Errorf("failed to decode options: %w", err)
-		}
-		if opts.Markup != "" {
-			opts.Markup = markup.ResolveMarkup(opts.Markup)
-		}
-	}
-
-	contentToRenderv := args[sidx]
-
-	if _, ok := contentToRenderv.(hstring.RenderedString); ok {
-		// This content is already rendered, this is potentially
-		// a infinite recursion.
-		return "", errors.New("text is already rendered, repeating it may cause infinite recursion")
-	}
-
-	var err error
-	contentToRender, err = cast.ToStringE(contentToRenderv)
+	summary, err := pco.mustRender(ctx).Summary(ctx)
 	if err != nil {
-		return "", err
-	}
-
-	if err = pco.initRenderHooks(); err != nil {
-		return "", err
-	}
-
-	conv := pco.po.p.getContentConverter()
-
-	if opts.Markup != "" && opts.Markup != pco.po.p.m.pageConfig.ContentMediaType.SubType {
-		var err error
-		conv, err = pco.po.p.m.newContentConverter(pco.po.p, opts.Markup)
-		if err != nil {
-			return "", pco.po.p.wrapError(err)
-		}
-	}
-
-	var rendered []byte
-
-	parseInfo := &contentParseInfo{
-		h:   pco.po.p.s.h,
-		pid: pco.po.p.pid,
-	}
-
-	if pageparser.HasShortcode(contentToRender) {
-		contentToRenderb := []byte(contentToRender)
-		// String contains a shortcode.
-		parseInfo.itemsStep1, err = pageparser.ParseBytes(contentToRenderb, pageparser.Config{
-			NoFrontMatter:    true,
-			NoSummaryDivider: true,
-		})
-		if err != nil {
-			return "", err
-		}
-
-		s := newShortcodeHandler(pco.po.p.pathOrTitle(), pco.po.p.s)
-		if err := parseInfo.mapItemsAfterFrontMatter(contentToRenderb, s); err != nil {
-			return "", err
-		}
-
-		placeholders, err := s.prepareShortcodesForPage(ctx, pco.po.p, pco.po.f, true)
-		if err != nil {
-			return "", err
-		}
-
-		contentToRender, hasVariants, err := parseInfo.contentToRender(ctx, contentToRenderb, placeholders)
-		if err != nil {
-			return "", err
-		}
-		if hasVariants {
-			pco.po.p.pageOutputTemplateVariationsState.Add(1)
-		}
-		b, err := pco.renderContentWithConverter(ctx, conv, contentToRender, false)
-		if err != nil {
-			return "", pco.po.p.wrapError(err)
-		}
-		rendered = b.Bytes()
-
-		if parseInfo.hasNonMarkdownShortcode {
-			var hasShortcodeVariants bool
-
-			tokenHandler := func(ctx context.Context, token string) ([]byte, error) {
-				if token == tocShortcodePlaceholder {
-					toc, err := pco.po.p.m.content.contentToC(ctx, pco)
-					if err != nil {
-						return nil, err
-					}
-					// The Page's TableOfContents was accessed in a shortcode.
-					return []byte(toc.tableOfContentsHTML), nil
-				}
-				renderer, found := placeholders[token]
-				if found {
-					repl, more, err := renderer.renderShortcode(ctx)
-					if err != nil {
-						return nil, err
-					}
-					hasShortcodeVariants = hasShortcodeVariants || more
-					return repl, nil
-				}
-				// This should not happen.
-				return nil, fmt.Errorf("unknown shortcode token %q", token)
-			}
-
-			rendered, err = expandShortcodeTokens(ctx, rendered, tokenHandler)
-			if err != nil {
-				return "", err
-			}
-			if hasShortcodeVariants {
-				pco.po.p.pageOutputTemplateVariationsState.Add(1)
-			}
-		}
-
-		// We need a consolidated view in $page.HasShortcode
-		pco.po.p.m.content.shortcodeState.transferNames(s)
-
-	} else {
-		c, err := pco.renderContentWithConverter(ctx, conv, []byte(contentToRender), false)
-		if err != nil {
-			return "", pco.po.p.wrapError(err)
-		}
-
-		rendered = c.Bytes()
-	}
-
-	if opts.Display == "inline" {
-		markup := pco.po.p.m.pageConfig.Content.Markup
-		if opts.Markup != "" {
-			markup = pco.po.p.s.ContentSpec.ResolveMarkup(opts.Markup)
-		}
-		rendered = pco.po.p.s.ContentSpec.TrimShortHTML(rendered, markup)
+		pco.fail(err)
 	}
-
-	return template.HTML(string(rendered)), nil
+	return summary.Text
 }
 
-func (pco *pageContentOutput) Render(ctx context.Context, layout ...string) (template.HTML, error) {
-	if len(layout) == 0 {
-		return "", errors.New("no layout given")
-	}
-	templ, found, err := pco.po.p.resolveTemplate(layout...)
+func (pco *pageContentOutput) Truncated(ctx context.Context) bool {
+	summary, err := pco.mustRender(ctx).Summary(ctx)
 	if err != nil {
-		return "", pco.po.p.wrapError(err)
-	}
-
-	if !found {
-		return "", nil
+		pco.fail(err)
 	}
+	return summary.Truncated
+}
 
-	// Make sure to send the *pageState and not the *pageContentOutput to the template.
-	res, err := executeToString(ctx, pco.po.p.s.Tmpl(), templ, pco.po.p)
-	if err != nil {
-		return "", pco.po.p.wrapError(fmt.Errorf("failed to execute template %s: %w", templ.Name(), err))
-	}
-	return template.HTML(res), nil
+func (pco *pageContentOutput) RenderString(ctx context.Context, args ...any) (template.HTML, error) {
+	return pco.c().RenderString(ctx, args...)
 }
 
 func (pco *pageContentOutput) initRenderHooks() error {
@@ -660,65 +470,3 @@ func executeToString(ctx context.Context, h tpl.TemplateHandler, templ tpl.Templ
 	}
 	return b.String(), nil
 }
-
-func splitUserDefinedSummaryAndContent(markup string, c []byte) (summary []byte, content []byte, err error) {
-	defer func() {
-		if r := recover(); r != nil {
-			err = fmt.Errorf("summary split failed: %s", r)
-		}
-	}()
-
-	startDivider := bytes.Index(c, internalSummaryDividerBaseBytes)
-
-	if startDivider == -1 {
-		return
-	}
-
-	startTag := "p"
-	switch markup {
-	case media.DefaultContentTypes.AsciiDoc.SubType:
-		startTag = "div"
-	}
-
-	// Walk back and forward to the surrounding tags.
-	start := bytes.LastIndex(c[:startDivider], []byte("<"+startTag))
-	end := bytes.Index(c[startDivider:], []byte("</"+startTag))
-
-	if start == -1 {
-		start = startDivider
-	} else {
-		start = startDivider - (startDivider - start)
-	}
-
-	if end == -1 {
-		end = startDivider + len(internalSummaryDividerBase)
-	} else {
-		end = startDivider + end + len(startTag) + 3
-	}
-
-	var addDiv bool
-
-	switch markup {
-	case "rst":
-		addDiv = true
-	}
-
-	withoutDivider := append(c[:start], bytes.Trim(c[end:], "\n")...)
-
-	if len(withoutDivider) > 0 {
-		summary = bytes.TrimSpace(withoutDivider[:start])
-	}
-
-	if addDiv {
-		// For the rst
-		summary = append(append([]byte(nil), summary...), []byte("</div>")...)
-	}
-
-	if err != nil {
-		return
-	}
-
-	content = bytes.TrimSpace(withoutDivider)
-
-	return
-}
diff --git a/hugolib/shortcode_page.go b/hugolib/shortcode_page.go
index 7c32f2ea147..8030b02851a 100644
--- a/hugolib/shortcode_page.go
+++ b/hugolib/shortcode_page.go
@@ -65,6 +65,7 @@ var zeroShortcode = prerenderedShortcode{}
 type pageForShortcode struct {
 	page.PageWithoutContent
 	page.TableOfContentsProvider
+	page.MarkupProvider
 	page.ContentProvider
 
 	// We need to replace it after we have rendered it, so provide a
@@ -80,6 +81,7 @@ func newPageForShortcode(p *pageState) page.Page {
 	return &pageForShortcode{
 		PageWithoutContent:      p,
 		TableOfContentsProvider: p,
+		MarkupProvider:          page.NopPage,
 		ContentProvider:         page.NopPage,
 		toc:                     template.HTML(tocShortcodePlaceholder),
 		p:                       p,
@@ -105,6 +107,7 @@ var _ types.Unwrapper = (*pageForRenderHooks)(nil)
 type pageForRenderHooks struct {
 	page.PageWithoutContent
 	page.TableOfContentsProvider
+	page.MarkupProvider
 	page.ContentProvider
 	p *pageState
 }
@@ -112,6 +115,7 @@ type pageForRenderHooks struct {
 func newPageForRenderHook(p *pageState) page.Page {
 	return &pageForRenderHooks{
 		PageWithoutContent:      p,
+		MarkupProvider:          page.NopPage,
 		ContentProvider:         page.NopPage,
 		TableOfContentsProvider: p,
 		p:                       p,
diff --git a/resources/page/page.go b/resources/page/page.go
index 9647a916b6d..4cda8d31fce 100644
--- a/resources/page/page.go
+++ b/resources/page/page.go
@@ -74,10 +74,17 @@ type ChildCareProvider interface {
 	Resources() resource.Resources
 }
 
+type MarkupProvider interface {
+	Markup(opts ...any) Markup
+}
+
 // ContentProvider provides the content related values for a Page.
 type ContentProvider interface {
 	Content(context.Context) (any, error)
 
+	// ContentWithoutSummary returns the Page Content stripped of the summary.
+	ContentWithoutSummary(ctx context.Context) (template.HTML, error)
+
 	// Plain returns the Page Content stripped of HTML markup.
 	Plain(context.Context) string
 
@@ -169,6 +176,7 @@ type PageProvider interface {
 
 // Page is the core interface in Hugo and what you get as the top level data context in your templates.
 type Page interface {
+	MarkupProvider
 	ContentProvider
 	TableOfContentsProvider
 	PageWithoutContent
@@ -260,7 +268,7 @@ type PageMetaInternalProvider interface {
 type PageRenderProvider interface {
 	// Render renders the given layout with this Page as context.
 	Render(ctx context.Context, layout ...string) (template.HTML, error)
-	// RenderString renders the first value in args with tPaginatorhe content renderer defined
+	// RenderString renders the first value in args with the content renderer defined
 	// for this Page.
 	// It takes an optional map as a second argument:
 	//
diff --git a/resources/page/page_lazy_contentprovider.go b/resources/page/page_lazy_contentprovider.go
index 665b2d00332..8e66a03e450 100644
--- a/resources/page/page_lazy_contentprovider.go
+++ b/resources/page/page_lazy_contentprovider.go
@@ -35,6 +35,7 @@ type OutputFormatContentProvider interface {
 
 // OutputFormatPageContentProvider holds the exported methods from Page that are "outputFormat aware".
 type OutputFormatPageContentProvider interface {
+	MarkupProvider
 	ContentProvider
 	TableOfContentsProvider
 	PageRenderProvider
@@ -74,6 +75,11 @@ func (lcp *LazyContentProvider) Reset() {
 	lcp.init.Reset()
 }
 
+func (lcp *LazyContentProvider) Markup(opts ...any) Markup {
+	lcp.init.Do(context.Background())
+	return lcp.cp.Markup(opts...)
+}
+
 func (lcp *LazyContentProvider) TableOfContents(ctx context.Context) template.HTML {
 	lcp.init.Do(ctx)
 	return lcp.cp.TableOfContents(ctx)
@@ -89,6 +95,11 @@ func (lcp *LazyContentProvider) Content(ctx context.Context) (any, error) {
 	return lcp.cp.Content(ctx)
 }
 
+func (lcp *LazyContentProvider) ContentWithoutSummary(ctx context.Context) (template.HTML, error) {
+	lcp.init.Do(ctx)
+	return lcp.cp.ContentWithoutSummary(ctx)
+}
+
 func (lcp *LazyContentProvider) Plain(ctx context.Context) string {
 	lcp.init.Do(ctx)
 	return lcp.cp.Plain(ctx)
diff --git a/resources/page/page_markup.go b/resources/page/page_markup.go
new file mode 100644
index 00000000000..ef4a56e3a74
--- /dev/null
+++ b/resources/page/page_markup.go
@@ -0,0 +1,344 @@
+// Copyright 2024 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package page
+
+import (
+	"context"
+	"html/template"
+	"regexp"
+	"strings"
+	"unicode"
+	"unicode/utf8"
+
+	"github.com/gohugoio/hugo/common/types"
+	"github.com/gohugoio/hugo/markup/tableofcontents"
+	"github.com/gohugoio/hugo/media"
+	"github.com/gohugoio/hugo/tpl"
+)
+
+type Content interface {
+	Content(context.Context) (template.HTML, error)
+	ContentWithoutSummary(context.Context) (template.HTML, error)
+	Summary(context.Context) (Summary, error)
+	Plain(context.Context) string
+	PlainWords(context.Context) []string
+	WordCount(context.Context) int
+	FuzzyWordCount(context.Context) int
+	ReadingTime(context.Context) int
+	Len(context.Context) int
+}
+
+type Markup interface {
+	Render(context.Context) (Content, error)
+	RenderString(ctx context.Context, args ...any) (template.HTML, error)
+	RenderShortcodes(context.Context) (template.HTML, error)
+	Fragments(context.Context) *tableofcontents.Fragments
+}
+
+var _ types.PrintableValueProvider = Summary{}
+
+const (
+	SummaryTypeAuto        = "auto"
+	SummaryTypeManual      = "manual"
+	SummaryTypeFrontMatter = "frontmatter"
+)
+
+type Summary struct {
+	Text      template.HTML
+	Type      string // "auto", "manual" or "frontmatter"
+	Truncated bool
+}
+
+func (s Summary) IsZero() bool {
+	return s.Text == ""
+}
+
+func (s Summary) PrintableValue() any {
+	return s.Text
+}
+
+var _ types.PrintableValueProvider = (*Summary)(nil)
+
+type HtmlSummary struct {
+	source         string
+	SummaryLowHigh types.LowHigh[string]
+	SummaryEndTag  types.LowHigh[string]
+	WrapperStart   types.LowHigh[string]
+	WrapperEnd     types.LowHigh[string]
+	Divider        types.LowHigh[string]
+}
+
+func (s HtmlSummary) wrap(ss string) string {
+	if s.WrapperStart.IsZero() {
+		return ss
+	}
+	return s.source[s.WrapperStart.Low:s.WrapperStart.High] + ss + s.source[s.WrapperEnd.Low:s.WrapperEnd.High]
+}
+
+func (s HtmlSummary) wrapLeft(ss string) string {
+	if s.WrapperStart.IsZero() {
+		return ss
+	}
+
+	return s.source[s.WrapperStart.Low:s.WrapperStart.High] + ss
+}
+
+func (s HtmlSummary) Value(l types.LowHigh[string]) string {
+	return s.source[l.Low:l.High]
+}
+
+func (s HtmlSummary) trimSpace(ss string) string {
+	return strings.TrimSpace(ss)
+}
+
+func (s HtmlSummary) Content() string {
+	if s.Divider.IsZero() {
+		return s.source
+	}
+	ss := s.source[:s.Divider.Low]
+	ss += s.source[s.Divider.High:]
+	return s.trimSpace(ss)
+}
+
+func (s HtmlSummary) Summary() string {
+	if s.Divider.IsZero() {
+		return s.trimSpace(s.wrap(s.Value(s.SummaryLowHigh)))
+	}
+	ss := s.source[s.SummaryLowHigh.Low:s.Divider.Low]
+	if s.SummaryLowHigh.High > s.Divider.High {
+		ss += s.source[s.Divider.High:s.SummaryLowHigh.High]
+	}
+	if !s.SummaryEndTag.IsZero() {
+		ss += s.Value(s.SummaryEndTag)
+	}
+	return s.trimSpace(s.wrap(ss))
+}
+
+func (s HtmlSummary) ContentWithoutSummary() string {
+	if s.Divider.IsZero() {
+		if s.SummaryLowHigh.Low == s.WrapperStart.High && s.SummaryLowHigh.High == s.WrapperEnd.Low {
+			return ""
+		}
+		return s.trimSpace(s.wrapLeft(s.source[s.SummaryLowHigh.High:]))
+	}
+	if s.SummaryEndTag.IsZero() {
+		return s.trimSpace(s.wrapLeft(s.source[s.Divider.High:]))
+	}
+	return s.trimSpace(s.wrapLeft(s.source[s.SummaryEndTag.High:]))
+}
+
+func (s HtmlSummary) Truncated() bool {
+	return s.SummaryLowHigh.High < len(s.source)
+}
+
+func (s *HtmlSummary) resolveParagraphTagAndSetWrapper(mt media.Type) tagReStartEnd {
+	ptag := startEndP
+
+	switch mt.SubType {
+	case media.DefaultContentTypes.AsciiDoc.SubType:
+		ptag = startEndDiv
+	case media.DefaultContentTypes.ReStructuredText.SubType:
+		const markerStart = "<div class=\"document\">"
+		const markerEnd = "</div>"
+		i1 := strings.Index(s.source, markerStart)
+		i2 := strings.LastIndex(s.source, markerEnd)
+		if i1 > -1 && i2 > -1 {
+			s.WrapperStart = types.LowHigh[string]{Low: 0, High: i1 + len(markerStart)}
+			s.WrapperEnd = types.LowHigh[string]{Low: i2, High: len(s.source)}
+		}
+	}
+	return ptag
+}
+
+// ExtractSummaryFromHTML extracts a summary from the given HTML content.
+func ExtractSummaryFromHTML(mt media.Type, input string, numWords int, isCJK bool) (result HtmlSummary) {
+	result.source = input
+	ptag := result.resolveParagraphTagAndSetWrapper(mt)
+
+	if numWords <= 0 {
+		return result
+	}
+
+	var count int
+
+	countWord := func(word string) int {
+		if isCJK {
+			word = tpl.StripHTML(word)
+			runeCount := utf8.RuneCountInString(word)
+			if len(word) == runeCount {
+				return 1
+			} else {
+				return runeCount
+			}
+		}
+
+		return 1
+	}
+
+	high := len(input)
+	if result.WrapperEnd.Low > 0 {
+		high = result.WrapperEnd.Low
+	}
+
+	for j := result.WrapperStart.High; j < high; {
+		s := input[j:]
+		closingIndex := strings.Index(s, "</"+ptag.tagName)
+
+		if closingIndex == -1 {
+			break
+		}
+
+		s = s[:closingIndex]
+
+		// Count the words in the current paragraph.
+		var wi int
+
+		for i, r := range s {
+			if unicode.IsSpace(r) || (i+utf8.RuneLen(r) == len(s)) {
+				word := s[wi:i]
+				count += countWord(word)
+				wi = i
+				if count >= numWords {
+					break
+				}
+			}
+		}
+
+		if count >= numWords {
+			result.SummaryLowHigh = types.LowHigh[string]{
+				Low:  result.WrapperStart.High,
+				High: j + closingIndex + len(ptag.tagName) + 3,
+			}
+			return
+		}
+
+		j += closingIndex + len(ptag.tagName) + 2
+
+	}
+
+	result.SummaryLowHigh = types.LowHigh[string]{
+		Low:  result.WrapperStart.High,
+		High: high,
+	}
+
+	return
+}
+
+// ExtractSummaryFromHTMLWithDivider extracts a summary from the given HTML content with
+// a manual summary divider.
+func ExtractSummaryFromHTMLWithDivider(mt media.Type, input, divider string) (result HtmlSummary) {
+	result.source = input
+	result.Divider.Low = strings.Index(input, divider)
+	result.Divider.High = result.Divider.Low + len(divider)
+
+	if result.Divider.Low == -1 {
+		// No summary.
+		return
+	}
+
+	ptag := result.resolveParagraphTagAndSetWrapper(mt)
+
+	if !mt.IsHTML() {
+		result.Divider, result.SummaryEndTag = expandSummaryDivider(result.source, ptag, result.Divider)
+	}
+
+	result.SummaryLowHigh = types.LowHigh[string]{
+		Low:  result.WrapperStart.High,
+		High: result.Divider.Low,
+	}
+
+	return
+}
+
+var (
+	pOrDiv = regexp.MustCompile(`<p[^>]?>|<div[^>]?>$`)
+
+	startEndDiv = tagReStartEnd{
+		startEndOfString: regexp.MustCompile(`<div[^>]*?>$`),
+		endEndOfString:   regexp.MustCompile(`</div>$`),
+		tagName:          "div",
+	}
+
+	startEndP = tagReStartEnd{
+		startEndOfString: regexp.MustCompile(`<p[^>]*?>$`),
+		endEndOfString:   regexp.MustCompile(`</p>$`),
+		tagName:          "p",
+	}
+)
+
+type tagReStartEnd struct {
+	startEndOfString *regexp.Regexp
+	endEndOfString   *regexp.Regexp
+	tagName          string
+}
+
+func expandSummaryDivider(s string, re tagReStartEnd, divider types.LowHigh[string]) (types.LowHigh[string], types.LowHigh[string]) {
+	var endMarkup types.LowHigh[string]
+
+	if divider.IsZero() {
+		return divider, endMarkup
+	}
+
+	lo, hi := divider.Low, divider.High
+
+	var preserveEndMarkup bool
+
+	// Find the start of the paragraph.
+
+	for i := lo - 1; i >= 0; i-- {
+		if s[i] == '>' {
+			if match := re.startEndOfString.FindString(s[:i+1]); match != "" {
+				lo = i - len(match) + 1
+				break
+			}
+			if match := pOrDiv.FindString(s[:i+1]); match != "" {
+				i -= len(match) - 1
+				continue
+			}
+		}
+
+		r, _ := utf8.DecodeRuneInString(s[i:])
+		if !unicode.IsSpace(r) {
+			preserveEndMarkup = true
+			break
+		}
+	}
+
+	divider.Low = lo
+
+	// Now walk forward to the end of the paragraph.
+	for ; hi < len(s); hi++ {
+		if s[hi] != '>' {
+			continue
+		}
+		if match := re.endEndOfString.FindString(s[:hi+1]); match != "" {
+			hi++
+			break
+		}
+	}
+
+	if preserveEndMarkup {
+		endMarkup.Low = divider.High
+		endMarkup.High = hi
+	} else {
+		divider.High = hi
+	}
+
+	// Consume trailing newline if any.
+	if divider.High < len(s) && s[divider.High] == '\n' {
+		divider.High++
+	}
+
+	return divider, endMarkup
+}
diff --git a/resources/page/page_nop.go b/resources/page/page_nop.go
index f745d8622d3..2b40dbb7329 100644
--- a/resources/page/page_nop.go
+++ b/resources/page/page_nop.go
@@ -44,6 +44,8 @@ import (
 var (
 	NopPage                 Page            = new(nopPage)
 	NopContentRenderer      ContentRenderer = new(nopContentRenderer)
+	NopMarkup               Markup          = new(nopMarkup)
+	NopContent              Content         = new(nopContent)
 	NopCPageContentRenderer                 = struct {
 		OutputFormatPageContentProvider
 		ContentRenderer
@@ -109,10 +111,18 @@ func (p *nopPage) BundleType() string {
 	return ""
 }
 
+func (p *nopPage) Markup(...any) Markup {
+	return NopMarkup
+}
+
 func (p *nopPage) Content(context.Context) (any, error) {
 	return "", nil
 }
 
+func (p *nopPage) ContentWithoutSummary(ctx context.Context) (template.HTML, error) {
+	return "", nil
+}
+
 func (p *nopPage) ContentBaseName() string {
 	return ""
 }
@@ -547,3 +557,69 @@ func (r *nopContentRenderer) ParseContent(ctx context.Context, content []byte) (
 func (r *nopContentRenderer) RenderContent(ctx context.Context, content []byte, doc any) (converter.ResultRender, bool, error) {
 	return nil, false, nil
 }
+
+type (
+	nopMarkup  int
+	nopContent int
+)
+
+var (
+	_ Markup  = (*nopMarkup)(nil)
+	_ Content = (*nopContent)(nil)
+)
+
+func (c *nopMarkup) Render(context.Context) (Content, error) {
+	return NopContent, nil
+}
+
+func (c *nopMarkup) RenderString(ctx context.Context, args ...any) (template.HTML, error) {
+	return "", nil
+}
+
+func (c *nopMarkup) RenderShortcodes(context.Context) (template.HTML, error) {
+	return "", nil
+}
+
+func (c *nopContent) Plain(context.Context) string {
+	return ""
+}
+
+func (c *nopContent) PlainWords(context.Context) []string {
+	return nil
+}
+
+func (c *nopContent) WordCount(context.Context) int {
+	return 0
+}
+
+func (c *nopContent) FuzzyWordCount(context.Context) int {
+	return 0
+}
+
+func (c *nopContent) ReadingTime(context.Context) int {
+	return 0
+}
+
+func (c *nopContent) Len(context.Context) int {
+	return 0
+}
+
+func (c *nopContent) Content(context.Context) (template.HTML, error) {
+	return "", nil
+}
+
+func (c *nopContent) ContentWithoutSummary(context.Context) (template.HTML, error) {
+	return "", nil
+}
+
+func (c *nopMarkup) Fragments(context.Context) *tableofcontents.Fragments {
+	return nil
+}
+
+func (c *nopMarkup) FragmentsHTML(context.Context) template.HTML {
+	return ""
+}
+
+func (c *nopContent) Summary(context.Context) (Summary, error) {
+	return Summary{}, nil
+}


Test patch:
diff --git a/common/hugo/hugo_test.go b/common/hugo/hugo_test.go
index 6e8b2620e4a..241d8c0ae80 100644
--- a/common/hugo/hugo_test.go
+++ b/common/hugo/hugo_test.go
@@ -14,6 +14,7 @@
 package hugo
 
 import (
+	"context"
 	"fmt"
 	"testing"
 
@@ -64,6 +65,19 @@ func TestDeprecationLogLevelFromVersion(t *testing.T) {
 	c.Assert(deprecationLogLevelFromVersion(ver.String()), qt.Equals, logg.LevelError)
 }
 
+func TestMarkupScope(t *testing.T) {
+	c := qt.New(t)
+
+	conf := testConfig{environment: "production", workingDir: "/mywork", running: false}
+	info := NewInfo(conf, nil)
+
+	ctx := context.Background()
+
+	ctx = SetMarkupScope(ctx, "foo")
+
+	c.Assert(info.Context.MarkupScope(ctx), qt.Equals, "foo")
+}
+
 type testConfig struct {
 	environment  string
 	running      bool
diff --git a/common/types/types_test.go b/common/types/types_test.go
index 6f13ae834c1..7957330477a 100644
--- a/common/types/types_test.go
+++ b/common/types/types_test.go
@@ -27,3 +27,25 @@ func TestKeyValues(t *testing.T) {
 	c.Assert(kv.KeyString(), qt.Equals, "key")
 	c.Assert(kv.Values, qt.DeepEquals, []any{"a1", "a2"})
 }
+
+func TestLowHigh(t *testing.T) {
+	c := qt.New(t)
+
+	lh := LowHigh[string]{
+		Low:  2,
+		High: 10,
+	}
+
+	s := "abcdefghijklmnopqrstuvwxyz"
+	c.Assert(lh.IsZero(), qt.IsFalse)
+	c.Assert(lh.Value(s), qt.Equals, "cdefghij")
+
+	lhb := LowHigh[[]byte]{
+		Low:  2,
+		High: 10,
+	}
+
+	sb := []byte(s)
+	c.Assert(lhb.IsZero(), qt.IsFalse)
+	c.Assert(lhb.Value(sb), qt.DeepEquals, []byte("cdefghij"))
+}
diff --git a/helpers/content_test.go b/helpers/content_test.go
index 22d4681910f..dd7c5a4c569 100644
--- a/helpers/content_test.go
+++ b/helpers/content_test.go
@@ -20,7 +20,6 @@ import (
 	"testing"
 
 	qt "github.com/frankban/quicktest"
-	"github.com/gohugoio/hugo/config"
 	"github.com/gohugoio/hugo/helpers"
 )
 
@@ -66,84 +65,6 @@ func TestBytesToHTML(t *testing.T) {
 	c.Assert(helpers.BytesToHTML([]byte("dobedobedo")), qt.Equals, template.HTML("dobedobedo"))
 }
 
-var benchmarkTruncateString = strings.Repeat("This is a sentence about nothing.", 20)
-
-func BenchmarkTestTruncateWordsToWholeSentence(b *testing.B) {
-	c := newTestContentSpec(nil)
-	b.ResetTimer()
-	for i := 0; i < b.N; i++ {
-		c.TruncateWordsToWholeSentence(benchmarkTruncateString)
-	}
-}
-
-func TestTruncateWordsToWholeSentence(t *testing.T) {
-	type test struct {
-		input, expected string
-		max             int
-		truncated       bool
-	}
-	data := []test{
-		{"a b c", "a b c", 12, false},
-		{"a b c", "a b c", 3, false},
-		{"a", "a", 1, false},
-		{"This is a sentence.", "This is a sentence.", 5, false},
-		{"This is also a sentence!", "This is also a sentence!", 1, false},
-		{"To be. Or not to be. That's the question.", "To be.", 1, true},
-		{" \nThis is not a sentence\nAnd this is another", "This is not a sentence", 4, true},
-		{"", "", 10, false},
-		{"This... is a more difficult test?", "This... is a more difficult test?", 1, false},
-	}
-	for i, d := range data {
-		cfg := config.New()
-		cfg.Set("summaryLength", d.max)
-		c := newTestContentSpec(cfg)
-		output, truncated := c.TruncateWordsToWholeSentence(d.input)
-		if d.expected != output {
-			t.Errorf("Test %d failed. Expected %q got %q", i, d.expected, output)
-		}
-
-		if d.truncated != truncated {
-			t.Errorf("Test %d failed. Expected truncated=%t got %t", i, d.truncated, truncated)
-		}
-	}
-}
-
-func TestTruncateWordsByRune(t *testing.T) {
-	type test struct {
-		input, expected string
-		max             int
-		truncated       bool
-	}
-	data := []test{
-		{"", "", 1, false},
-		{"a b c", "a b c", 12, false},
-		{"a b c", "a b c", 3, false},
-		{"a", "a", 1, false},
-		{"Hello 中国", "", 0, true},
-		{"这是中文，全中文。", "这是中文，", 5, true},
-		{"Hello 中国", "Hello 中", 2, true},
-		{"Hello 中国", "Hello 中国", 3, false},
-		{"Hello中国 Good 好的", "Hello中国 Good 好", 9, true},
-		{"This is a sentence.", "This is", 2, true},
-		{"This is also a sentence!", "This", 1, true},
-		{"To be. Or not to be. That's the question.", "To be. Or not", 4, true},
-		{" \nThis is    not a sentence\n ", "This is not", 3, true},
-	}
-	for i, d := range data {
-		cfg := config.New()
-		cfg.Set("summaryLength", d.max)
-		c := newTestContentSpec(cfg)
-		output, truncated := c.TruncateWordsByRune(strings.Fields(d.input))
-		if d.expected != output {
-			t.Errorf("Test %d failed. Expected %q got %q", i, d.expected, output)
-		}
-
-		if d.truncated != truncated {
-			t.Errorf("Test %d failed. Expected truncated=%t got %t", i, d.truncated, truncated)
-		}
-	}
-}
-
 func TestExtractTOCNormalContent(t *testing.T) {
 	content := []byte("<nav>\n<ul>\nTOC<li><a href=\"#")
 
diff --git a/hugolib/page_test.go b/hugolib/page_test.go
index a1eb4343028..47f7c59c9fd 100644
--- a/hugolib/page_test.go
+++ b/hugolib/page_test.go
@@ -63,15 +63,6 @@ Summary Next Line
 
 <!--more-->
 Some more text
-`
-
-	simplePageWithBlankSummary = `---
-title: SimpleWithBlankSummary
----
-
-<!--more-->
-
-Some text.
 `
 
 	simplePageWithSummaryParameter = `---
@@ -322,7 +313,8 @@ func checkPageTOC(t *testing.T, page page.Page, toc string) {
 }
 
 func checkPageSummary(t *testing.T, page page.Page, summary string, msg ...any) {
-	a := normalizeContent(string(page.Summary(context.Background())))
+	s := string(page.Summary(context.Background()))
+	a := normalizeContent(s)
 	b := normalizeContent(summary)
 	if a != b {
 		t.Fatalf("Page summary is:\n%q.\nExpected\n%q (%q)", a, b, msg)
@@ -593,26 +585,6 @@ date: 2012-01-12
 	b.Assert(s.Site().Lastmod().Year(), qt.Equals, 2018)
 }
 
-func TestCreateNewPage(t *testing.T) {
-	t.Parallel()
-	c := qt.New(t)
-	assertFunc := func(t *testing.T, ext string, pages page.Pages) {
-		p := pages[0]
-
-		// issue #2290: Path is relative to the content dir and will continue to be so.
-		c.Assert(p.File().Path(), qt.Equals, fmt.Sprintf("p0.%s", ext))
-		c.Assert(p.IsHome(), qt.Equals, false)
-		checkPageTitle(t, p, "Simple")
-		checkPageContent(t, p, normalizeExpected(ext, "<p>Simple Page</p>\n"))
-		checkPageSummary(t, p, "Simple Page")
-		checkPageType(t, p, "page")
-	}
-
-	settings := map[string]any{}
-
-	testAllMarkdownEnginesForPages(t, assertFunc, settings, simplePage)
-}
-
 func TestPageSummary(t *testing.T) {
 	t.Parallel()
 	assertFunc := func(t *testing.T, ext string, pages page.Pages) {
@@ -621,7 +593,7 @@ func TestPageSummary(t *testing.T) {
 		// Source is not Asciidoctor- or RST-compatible so don't test them
 		if ext != "ad" && ext != "rst" {
 			checkPageContent(t, p, normalizeExpected(ext, "<p><a href=\"https://lipsum.com/\">Lorem ipsum</a> dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>\n\n<p>Additional text.</p>\n\n<p>Further text.</p>\n"), ext)
-			checkPageSummary(t, p, normalizeExpected(ext, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Additional text."), ext)
+			checkPageSummary(t, p, normalizeExpected(ext, "<p><a href=\"https://lipsum.com/\">Lorem ipsum</a> dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>"), ext)
 		}
 		checkPageType(t, p, "page")
 	}
@@ -642,19 +614,6 @@ func TestPageWithDelimiter(t *testing.T) {
 	testAllMarkdownEnginesForPages(t, assertFunc, nil, simplePageWithSummaryDelimiter)
 }
 
-func TestPageWithBlankSummary(t *testing.T) {
-	t.Parallel()
-	assertFunc := func(t *testing.T, ext string, pages page.Pages) {
-		p := pages[0]
-		checkPageTitle(t, p, "SimpleWithBlankSummary")
-		checkPageContent(t, p, normalizeExpected(ext, "<p>Some text.</p>\n"), ext)
-		checkPageSummary(t, p, normalizeExpected(ext, ""), ext)
-		checkPageType(t, p, "page")
-	}
-
-	testAllMarkdownEnginesForPages(t, assertFunc, nil, simplePageWithBlankSummary)
-}
-
 func TestPageWithSummaryParameter(t *testing.T) {
 	t.Parallel()
 	assertFunc := func(t *testing.T, ext string, pages page.Pages) {
@@ -729,19 +688,6 @@ title: "empty"
 	b.AssertFileContent("public/empty/index.html", "! title")
 }
 
-func TestPageWithShortCodeInSummary(t *testing.T) {
-	t.Parallel()
-	assertFunc := func(t *testing.T, ext string, pages page.Pages) {
-		p := pages[0]
-		checkPageTitle(t, p, "Simple")
-		checkPageContent(t, p, normalizeExpected(ext, "<p>Summary Next Line. <figure><img src=\"/not/real\"> </figure> . More text here.</p><p>Some more text</p>"))
-		checkPageSummary(t, p, "Summary Next Line.  . More text here. Some more text")
-		checkPageType(t, p, "page")
-	}
-
-	testAllMarkdownEnginesForPages(t, assertFunc, nil, simplePageWithShortcodeInSummary)
-}
-
 func TestTableOfContents(t *testing.T) {
 	c := qt.New(t)
 	cfg, fs := newTestCfg()
@@ -853,7 +799,7 @@ Summary: {{ .Summary }}|Truncated: {{ .Truncated }}|
 Content: {{ .Content }}|
 
 `).AssertFileContent("public/simple/index.html",
-		"Summary: This is summary. This is more summary. This is even more summary*.|",
+		"Summary: <p>This is <strong>summary</strong>.\nThis is <strong>more summary</strong>.\nThis is <em>even more summary</em>*.\nThis is <strong>more summary</strong>.</p>|",
 		"Truncated: true|",
 		"Content: <p>This is <strong>summary</strong>.")
 }
@@ -1242,11 +1188,6 @@ func TestWordCountWithMainEnglishWithCJKRunes(t *testing.T) {
 		if p.WordCount(context.Background()) != 74 {
 			t.Fatalf("[%s] incorrect word count, expected %v, got %v", ext, 74, p.WordCount(context.Background()))
 		}
-
-		if p.Summary(context.Background()) != simplePageWithMainEnglishWithCJKRunesSummary {
-			t.Fatalf("[%s] incorrect Summary for content '%s'. expected\n%v, got\n%v", ext, p.Plain(context.Background()),
-				simplePageWithMainEnglishWithCJKRunesSummary, p.Summary(context.Background()))
-		}
 	}
 
 	testAllMarkdownEnginesForPages(t, assertFunc, settings, simplePageWithMainEnglishWithCJKRunes)
@@ -1263,11 +1204,6 @@ func TestWordCountWithIsCJKLanguageFalse(t *testing.T) {
 		if p.WordCount(context.Background()) != 75 {
 			t.Fatalf("[%s] incorrect word count for content '%s'. expected %v, got %v", ext, p.Plain(context.Background()), 74, p.WordCount(context.Background()))
 		}
-
-		if p.Summary(context.Background()) != simplePageWithIsCJKLanguageFalseSummary {
-			t.Fatalf("[%s] incorrect Summary for content '%s'. expected %v, got %v", ext, p.Plain(context.Background()),
-				simplePageWithIsCJKLanguageFalseSummary, p.Summary(context.Background()))
-		}
 	}
 
 	testAllMarkdownEnginesForPages(t, assertFunc, settings, simplePageWithIsCJKLanguageFalse)
@@ -1485,42 +1421,6 @@ func TestChompBOM(t *testing.T) {
 	checkPageTitle(t, p, "Simple")
 }
 
-func TestPageHTMLContent(t *testing.T) {
-	b := newTestSitesBuilder(t)
-	b.WithSimpleConfigFile()
-
-	frontmatter := `---
-title: "HTML Content"
----
-`
-	b.WithContent("regular.html", frontmatter+`<h1>Hugo</h1>`)
-	b.WithContent("nomarkdownforyou.html", frontmatter+`**Hugo!**`)
-	b.WithContent("manualsummary.html", frontmatter+`
-<p>This is summary</p>
-<!--more-->
-<p>This is the main content.</p>`)
-
-	b.Build(BuildCfg{})
-
-	b.AssertFileContent(
-		"public/regular/index.html",
-		"Single: HTML Content|Hello|en|RelPermalink: /regular/|",
-		"Summary: Hugo|Truncated: false")
-
-	b.AssertFileContent(
-		"public/nomarkdownforyou/index.html",
-		"Permalink: http://example.com/nomarkdownforyou/|**Hugo!**|",
-	)
-
-	// https://github.com/gohugoio/hugo/issues/5723
-	b.AssertFileContent(
-		"public/manualsummary/index.html",
-		"Single: HTML Content|Hello|en|RelPermalink: /manualsummary/|",
-		"Summary: \n<p>This is summary</p>\n|Truncated: true",
-		"|<p>This is the main content.</p>|",
-	)
-}
-
 // https://github.com/gohugoio/hugo/issues/5381
 func TestPageManualSummary(t *testing.T) {
 	b := newTestSitesBuilder(t)
@@ -1761,102 +1661,6 @@ Single: {{ .Title}}|{{ .RelPermalink }}|{{ .Path }}|
 	b.AssertFileContent("public/sect3/Pag.E4/index.html", "Single: Pag.E4|/sect3/Pag.E4/|/sect3/p4|")
 }
 
-// https://github.com/gohugoio/hugo/issues/4675
-func TestWordCountAndSimilarVsSummary(t *testing.T) {
-	t.Parallel()
-	c := qt.New(t)
-
-	single := []string{"_default/single.html", `
-WordCount: {{ .WordCount }}
-FuzzyWordCount: {{ .FuzzyWordCount }}
-ReadingTime: {{ .ReadingTime }}
-Len Plain: {{ len .Plain }}
-Len PlainWords: {{ len .PlainWords }}
-Truncated: {{ .Truncated }}
-Len Summary: {{ len .Summary }}
-Len Content: {{ len .Content }}
-
-SUMMARY:{{ .Summary }}:{{ len .Summary }}:END
-
-`}
-
-	b := newTestSitesBuilder(t)
-	b.WithSimpleConfigFile().WithTemplatesAdded(single...).WithContent("p1.md", fmt.Sprintf(`---
-title: p1
----
-
-%s
-
-`, strings.Repeat("word ", 510)),
-
-		"p2.md", fmt.Sprintf(`---
-title: p2
----
-This is a summary.
-
-<!--more-->
-
-%s
-
-`, strings.Repeat("word ", 310)),
-		"p3.md", fmt.Sprintf(`---
-title: p3
-isCJKLanguage: true
----
-Summary: In Chinese, 好 means good.
-
-<!--more-->
-
-%s
-
-`, strings.Repeat("好", 200)),
-		"p4.md", fmt.Sprintf(`---
-title: p4
-isCJKLanguage: false
----
-Summary: In Chinese, 好 means good.
-
-<!--more-->
-
-%s
-
-`, strings.Repeat("好", 200)),
-
-		"p5.md", fmt.Sprintf(`---
-title: p4
-isCJKLanguage: true
----
-Summary: In Chinese, 好 means good.
-
-%s
-
-`, strings.Repeat("好", 200)),
-		"p6.md", fmt.Sprintf(`---
-title: p4
-isCJKLanguage: false
----
-Summary: In Chinese, 好 means good.
-
-%s
-
-`, strings.Repeat("好", 200)),
-	)
-
-	b.CreateSites().Build(BuildCfg{})
-
-	c.Assert(len(b.H.Sites), qt.Equals, 1)
-	c.Assert(len(b.H.Sites[0].RegularPages()), qt.Equals, 6)
-
-	b.AssertFileContent("public/p1/index.html", "WordCount: 510\nFuzzyWordCount: 600\nReadingTime: 3\nLen Plain: 2550\nLen PlainWords: 510\nTruncated: false\nLen Summary: 2549\nLen Content: 2557")
-
-	b.AssertFileContent("public/p2/index.html", "WordCount: 314\nFuzzyWordCount: 400\nReadingTime: 2\nLen Plain: 1569\nLen PlainWords: 314\nTruncated: true\nLen Summary: 25\nLen Content: 1582")
-
-	b.AssertFileContent("public/p3/index.html", "WordCount: 206\nFuzzyWordCount: 300\nReadingTime: 1\nLen Plain: 638\nLen PlainWords: 7\nTruncated: true\nLen Summary: 43\nLen Content: 651")
-	b.AssertFileContent("public/p4/index.html", "WordCount: 7\nFuzzyWordCount: 100\nReadingTime: 1\nLen Plain: 638\nLen PlainWords: 7\nTruncated: true\nLen Summary: 43\nLen Content: 651")
-	b.AssertFileContent("public/p5/index.html", "WordCount: 206\nFuzzyWordCount: 300\nReadingTime: 1\nLen Plain: 638\nLen PlainWords: 7\nTruncated: true\nLen Summary: 229\nLen Content: 652")
-	b.AssertFileContent("public/p6/index.html", "WordCount: 7\nFuzzyWordCount: 100\nReadingTime: 1\nLen Plain: 638\nLen PlainWords: 7\nTruncated: false\nLen Summary: 637\nLen Content: 652")
-}
-
 func TestScratch(t *testing.T) {
 	t.Parallel()
 
diff --git a/hugolib/shortcode_test.go b/hugolib/shortcode_test.go
index a1c5c0aea2a..21436d980dc 100644
--- a/hugolib/shortcode_test.go
+++ b/hugolib/shortcode_test.go
@@ -756,12 +756,15 @@ title: "Hugo Rocks!"
 
 func TestShortcodeParams(t *testing.T) {
 	t.Parallel()
-	c := qt.New(t)
-
-	builder := newTestSitesBuilder(t).WithSimpleConfigFile()
 
-	builder.WithContent("page.md", `---
+	files := `
+-- hugo.toml --
+baseURL = "https://example.org"
+-- layouts/shortcodes/hello.html --
+{{ range $i, $v := .Params }}{{ printf "- %v: %v (%T) " $i $v $v -}}{{ end }}
+-- content/page.md --
 title: "Hugo Rocks!"
+summary: "Foo"
 ---
 
 # doc
@@ -770,23 +773,15 @@ types positional: {{< hello true false 33 3.14 >}}
 types named: {{< hello b1=true b2=false i1=33 f1=3.14 >}}
 types string: {{< hello "true" trues "33" "3.14" >}}
 escaped quoute: {{< hello "hello \"world\"." >}}
+-- layouts/_default/single.html --
+Content: {{ .Content }}|
+`
 
+	b := Test(t, files)
 
-`).WithTemplatesAdded(
-		"layouts/shortcodes/hello.html",
-		`{{ range $i, $v := .Params }}
--  {{ printf "%v: %v (%T)" $i $v $v }}
-{{ end }}
-{{ $b1 := .Get "b1" }}
-Get: {{ printf "%v (%T)" $b1 $b1 | safeHTML }}
-`).Build(BuildCfg{})
-
-	s := builder.H.Sites[0]
-	c.Assert(len(s.RegularPages()), qt.Equals, 1)
-
-	builder.AssertFileContent("public/page/index.html",
+	b.AssertFileContent("public/page/index.html",
 		"types positional: - 0: true (bool) - 1: false (bool) - 2: 33 (int) - 3: 3.14 (float64)",
-		"types named: - b1: true (bool) - b2: false (bool) - f1: 3.14 (float64) - i1: 33 (int) Get: true (bool) ",
+		"types named: - b1: true (bool) - b2: false (bool) - f1: 3.14 (float64) - i1: 33 (int)",
 		"types string: - 0: true (string) - 1: trues (string) - 2: 33 (string) - 3: 3.14 (string) ",
 		"hello &#34;world&#34;. (string)",
 	)
diff --git a/resources/page/page_markup_integration_test.go b/resources/page/page_markup_integration_test.go
new file mode 100644
index 00000000000..fc3c6a5691e
--- /dev/null
+++ b/resources/page/page_markup_integration_test.go
@@ -0,0 +1,337 @@
+// Copyright 2024 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package page_test
+
+import (
+	"testing"
+
+	"github.com/gohugoio/hugo/hugolib"
+	"github.com/gohugoio/hugo/markup/asciidocext"
+	"github.com/gohugoio/hugo/markup/rst"
+)
+
+func TestPageMarkupMethods(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+summaryLength=2
+-- content/p1.md --
+---
+title: "Post 1"
+date: "2020-01-01"
+---
+{{% foo %}}
+-- layouts/shortcodes/foo.html --
+Two *words*.
+{{/* Test that markup scope is set in all relevant constructs. */}}
+{{ if eq hugo.Context.MarkupScope "foo" }}
+
+## Heading 1
+Sint ad mollit qui Lorem ut occaecat culpa officia. Et consectetur aute voluptate non sit ullamco adipisicing occaecat. Sunt deserunt amet sit ad. Deserunt enim voluptate proident ipsum dolore dolor ut sit velit esse est mollit irure esse. Mollit incididunt veniam laboris magna et excepteur sit duis. Magna adipisicing reprehenderit tempor irure.
+### Heading 2
+Exercitation quis est consectetur occaecat nostrud. Ullamco aute mollit aliqua est amet. Exercitation ullamco consectetur dolor labore et non irure eu cillum Lorem.
+{{ end }}
+-- layouts/index.html --
+Home.
+{{ .Content }}
+-- layouts/_default/single.html --
+Single.
+Page.ContentWithoutSummmary: {{ .ContentWithoutSummary }}|
+{{ template "render-scope" (dict "page" . "scope" "main") }}
+{{ template "render-scope" (dict "page" . "scope" "foo") }}
+{{ define "render-scope" }}
+{{ $c := .page.Markup .scope }}
+{{ with $c.Render }}
+{{ $.scope }}: Content: {{ .Content }}|
+ {{ $.scope }}: ContentWithoutSummary: {{ .ContentWithoutSummary }}|
+{{ $.scope }}: Plain: {{ .Plain }}|
+{{ $.scope }}: PlainWords: {{ .PlainWords }}|
+{{ $.scope }}: WordCount: {{ .WordCount }}|
+{{ $.scope }}: FuzzyWordCount: {{ .FuzzyWordCount }}|
+{{ $.scope }}: ReadingTime: {{ .ReadingTime }}|
+{{ $.scope }}: Len: {{ .Len }}|
+{{ $.scope }}: Summary: {{ with .Summary }}{{ . }}{{ else }}nil{{ end }}|
+{{ end }}
+{{ $.scope }}: Fragments: {{ $c.Fragments.Identifiers }}|
+{{ end }}
+
+
+
+`
+
+	b := hugolib.Test(t, files)
+
+	// Main scope.
+	b.AssertFileContent("public/p1/index.html",
+		"Page.ContentWithoutSummmary: |",
+		"main: Content: <p>Two <em>words</em>.</p>\n|",
+		"main: ContentWithoutSummary: |",
+		"main: Plain: Two words.\n|",
+		"PlainWords: [Two words.]|\nmain: WordCount: 2|\nmain: FuzzyWordCount: 100|\nmain: ReadingTime: 1|",
+		"main: Summary: <p>Two <em>words</em>.</p>|\n\nmain: Fragments: []|",
+		"main: Len: 27|",
+	)
+
+	// Foo scope (has more content).
+	b.AssertFileContent("public/p1/index.html",
+		"foo: Content: <p>Two <em>words</em>.</p>\n<h2",
+		"foo: ContentWithoutSummary: <h2",
+		"Plain: Two words.\nHeading 1",
+		"PlainWords: [Two words. Heading 1",
+		"foo: WordCount: 81|\nfoo: FuzzyWordCount: 100|\nfoo: ReadingTime: 1|\nfoo: Len: 622|",
+		"foo: Summary: <p>Two <em>words</em>.</p>|",
+		"foo: Fragments: [heading-1 heading-2]|",
+	)
+}
+
+func TestPageMarkupScope(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+disableKinds = ["taxonomy", "term", "rss", "section"]
+-- content/p1.md --
+---
+title: "Post 1"
+date: "2020-01-01"
+---
+
+# P1
+
+{{< foo >}}
+
+Begin:{{% includerendershortcodes "p2" %}}:End
+Begin:{{< includecontent "p3" >}}:End
+
+-- content/p2.md --
+---
+title: "Post 2"
+date: "2020-01-02"
+---
+
+# P2
+-- content/p3.md --
+---
+title: "Post 3"
+date: "2020-01-03"
+---
+
+# P3
+
+{{< foo >}}
+
+-- layouts/index.html --
+Home.
+{{ with site.GetPage "p1" }}
+	{{ with .Markup "home" }}
+	 	{{ .Render.Content }}
+	{{ end }}
+{{ end }}
+-- layouts/_default/single.html --
+Single.
+{{ with .Markup  }}
+	{{ with .Render }}
+	 	{{ .Content }}
+	{{ end }}
+{{ end }}
+-- layouts/_default/_markup/render-heading.html --
+Render heading: title: {{ .Text}} scope: {{ hugo.Context.MarkupScope }}|
+-- layouts/shortcodes/foo.html --
+Foo scope: {{ hugo.Context.MarkupScope }}|
+-- layouts/shortcodes/includerendershortcodes.html --
+{{ $p := site.GetPage (.Get 0) }}
+includerendershortcodes: {{ hugo.Context.MarkupScope }}|{{ $p.Markup.RenderShortcodes }}|
+-- layouts/shortcodes/includecontent.html --
+{{ $p := site.GetPage (.Get 0) }}
+includecontent: {{ hugo.Context.MarkupScope }}|{{ $p.Markup.Render.Content }}|
+
+`
+
+	b := hugolib.Test(t, files)
+
+	b.AssertFileContent("public/p1/index.html", "Render heading: title: P1 scope: |", "Foo scope: |")
+
+	b.AssertFileContent("public/index.html",
+		"Render heading: title: P1 scope: home|",
+		"Foo scope: home|",
+		"Begin:\nincluderendershortcodes: home|</p>\nRender heading: title: P2 scope: home|<p>|:End",
+		"Begin:\nincludecontent: home|Render heading: title: P3 scope: home|Foo scope: home|\n|\n:End",
+	)
+}
+
+func TestPageMarkupWithoutSummary(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+summaryLength=5
+-- content/p1.md --
+---
+title: "Post 1"
+date: "2020-01-01"
+---
+This is summary.
+<!--more-->
+This is content.
+-- content/p2.md --
+---
+title: "Post 2"
+date: "2020-01-01"
+---
+This is some content about a summary and more.
+
+Another paragraph.
+
+Third paragraph.
+-- layouts/_default/single.html --
+Single.
+Page.Summary: {{ .Summary }}|
+{{ with .Markup.Render }}
+Content: {{ .Content }}|
+ContentWithoutSummary: {{ .ContentWithoutSummary }}|
+WordCount: {{ .WordCount }}|
+FuzzyWordCount: {{ .FuzzyWordCount }}|
+{{ with .Summary }}
+Summary: {{ . }}|
+Summary Type: {{ .Type }}|
+Summary Truncated: {{ .Truncated }}|
+{{ end }}
+{{ end }}
+
+`
+	b := hugolib.Test(t, files)
+
+	b.AssertFileContentExact("public/p1/index.html",
+		"Content: <p>This is summary.</p>\n<p>This is content.</p>",
+		"ContentWithoutSummary: <p>This is content.</p>|",
+		"WordCount: 6|",
+		"FuzzyWordCount: 100|",
+		"Summary: <p>This is summary.</p>|",
+		"Summary Type: manual|",
+		"Summary Truncated: true|",
+	)
+	b.AssertFileContent("public/p2/index.html",
+		"Summary: <p>This is some content about a summary and more.</p>|",
+		"WordCount: 13|",
+		"FuzzyWordCount: 100|",
+		"Summary Type: auto",
+		"Summary Truncated: true",
+	)
+}
+
+func TestPageMarkupWithoutSummaryRST(t *testing.T) {
+	t.Parallel()
+	if !rst.Supports() {
+		t.Skip("Skip RST test as not supported")
+	}
+
+	files := `
+-- hugo.toml --
+summaryLength=5
+[security.exec]
+allow = ["rst", "python"]
+
+-- content/p1.rst --
+This is a story about a summary and more.
+
+Another paragraph.
+-- content/p2.rst --
+This is summary.
+<!--more-->
+This is content.
+-- layouts/_default/single.html --
+Single.
+Page.Summary: {{ .Summary }}|
+{{ with .Markup.Render }}
+Content: {{ .Content }}|
+ContentWithoutSummary: {{ .ContentWithoutSummary }}|
+{{ with .Summary }}
+Summary: {{ . }}|
+Summary Type: {{ .Type }}|
+Summary Truncated: {{ .Truncated }}|
+{{ end }}
+{{ end }}
+
+`
+
+	b := hugolib.Test(t, files)
+
+	// Auto summary.
+	b.AssertFileContentExact("public/p1/index.html",
+		"Content: <div class=\"document\">\n\n\n<p>This is a story about a summary and more.</p>\n<p>Another paragraph.</p>\n</div>|",
+		"Summary: <div class=\"document\">\n\n\n<p>This is a story about a summary and more.</p></div>|\nSummary Type: auto|\nSummary Truncated: true|",
+		"ContentWithoutSummary: <div class=\"document\">\n<p>Another paragraph.</p>\n</div>|",
+	)
+
+	// Manual summary.
+	b.AssertFileContentExact("public/p2/index.html",
+		"Content: <div class=\"document\">\n\n\n<p>This is summary.</p>\n<p>This is content.</p>\n</div>|",
+		"ContentWithoutSummary: <div class=\"document\"><p>This is content.</p>\n</div>|",
+		"Summary: <div class=\"document\">\n\n\n<p>This is summary.</p>\n</div>|\nSummary Type: manual|\nSummary Truncated: true|",
+	)
+}
+
+func TestPageMarkupWithoutSummaryAsciidoc(t *testing.T) {
+	t.Parallel()
+	if !asciidocext.Supports() {
+		t.Skip("Skip asiidoc test as not supported")
+	}
+
+	files := `
+-- hugo.toml --
+summaryLength=5
+[security.exec]
+allow = ["asciidoc", "python"]
+
+-- content/p1.ad --
+This is a story about a summary and more.
+
+Another paragraph.
+-- content/p2.ad --
+This is summary.
+<!--more-->
+This is content.
+-- layouts/_default/single.html --
+Single.
+Page.Summary: {{ .Summary }}|
+{{ with .Markup.Render }}
+Content: {{ .Content }}|
+ContentWithoutSummary: {{ .ContentWithoutSummary }}|
+{{ with .Summary }}
+Summary: {{ . }}|
+Summary Type: {{ .Type }}|
+Summary Truncated: {{ .Truncated }}|
+{{ end }}
+{{ end }}
+
+`
+
+	b := hugolib.Test(t, files)
+
+	// Auto summary.
+	b.AssertFileContentExact("public/p1/index.html",
+		"Content: <div class=\"paragraph\">\n<p>This is a story about a summary and more.</p>\n</div>\n<div class=\"paragraph\">\n<p>Another paragraph.</p>\n</div>\n|",
+		"Summary: <div class=\"paragraph\">\n<p>This is a story about a summary and more.</p>\n</div>|",
+		"Summary Type: auto|\nSummary Truncated: true|",
+		"ContentWithoutSummary: <div class=\"paragraph\">\n<p>Another paragraph.</p>\n</div>|",
+	)
+
+	// Manual summary.
+	b.AssertFileContentExact("public/p2/index.html",
+		"Content: <div class=\"paragraph\">\n<p>This is summary.</p>\n</div>\n<div class=\"paragraph\">\n<p>This is content.</p>\n</div>|",
+		"ContentWithoutSummary: <div class=\"paragraph\">\n<p>This is content.</p>\n</div>|",
+		"Summary: <div class=\"paragraph\">\n<p>This is summary.</p>\n</div>|\nSummary Type: manual|\nSummary Truncated: true|",
+	)
+}
diff --git a/resources/page/page_markup_test.go b/resources/page/page_markup_test.go
new file mode 100644
index 00000000000..b7d363f8f68
--- /dev/null
+++ b/resources/page/page_markup_test.go
@@ -0,0 +1,151 @@
+// Copyright 2024 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package page
+
+import (
+	"strings"
+	"testing"
+
+	qt "github.com/frankban/quicktest"
+	"github.com/gohugoio/hugo/common/types"
+	"github.com/gohugoio/hugo/media"
+)
+
+func TestExtractSummaryFromHTML(t *testing.T) {
+	c := qt.New(t)
+
+	tests := []struct {
+		mt                          media.Type
+		input                       string
+		isCJK                       bool
+		numWords                    int
+		expectSummary               string
+		expectContentWithoutSummary string
+	}{
+		{media.Builtin.ReStructuredTextType, "<div class=\"document\">\n\n\n<p>Simple Page</p>\n</div>", false, 70, "<div class=\"document\">\n\n\n<p>Simple Page</p>\n</div>", ""},
+		{media.Builtin.ReStructuredTextType, "<div class=\"document\"><p>First paragraph</p><p>Second paragraph</p></div>", false, 2, `<div class="document"><p>First paragraph</p></div>`, "<div class=\"document\"><p>Second paragraph</p></div>"},
+		{media.Builtin.MarkdownType, "<p>First paragraph</p>", false, 10, "<p>First paragraph</p>", ""},
+		{media.Builtin.MarkdownType, "<p>First paragraph</p><p>Second paragraph</p>", false, 2, "<p>First paragraph</p>", "<p>Second paragraph</p>"},
+		{media.Builtin.MarkdownType, "<p>First paragraph</p><p>Second paragraph</p><p>Third paragraph</p>", false, 3, "<p>First paragraph</p><p>Second paragraph</p>", "<p>Third paragraph</p>"},
+		{media.Builtin.AsciiDocType, "<div><p>First paragraph</p></div><div><p>Second paragraph</p></div>", false, 2, "<div><p>First paragraph</p></div>", "<div><p>Second paragraph</p></div>"},
+		{media.Builtin.MarkdownType, "<p>这是中文，全中文</p><p>a这是中文，全中文</p>", true, 5, "<p>这是中文，全中文</p>", "<p>a这是中文，全中文</p>"},
+	}
+
+	for i, test := range tests {
+		summary := ExtractSummaryFromHTML(test.mt, test.input, test.numWords, test.isCJK)
+		c.Assert(summary.Summary(), qt.Equals, test.expectSummary, qt.Commentf("Summary %d", i))
+		c.Assert(summary.ContentWithoutSummary(), qt.Equals, test.expectContentWithoutSummary, qt.Commentf("ContentWithoutSummary %d", i))
+	}
+}
+
+func TestExtractSummaryFromHTMLWithDivider(t *testing.T) {
+	c := qt.New(t)
+
+	const divider = "FOOO"
+
+	tests := []struct {
+		mt                          media.Type
+		input                       string
+		expectSummary               string
+		expectContentWithoutSummary string
+		expectContent               string
+	}{
+		{media.Builtin.MarkdownType, "<p>First paragraph</p><p>FOOO</p><p>Second paragraph</p>", "<p>First paragraph</p>", "<p>Second paragraph</p>", "<p>First paragraph</p><p>Second paragraph</p>"},
+		{media.Builtin.MarkdownType, "<p>First paragraph</p>\n<p>FOOO</p>\n<p>Second paragraph</p>", "<p>First paragraph</p>", "<p>Second paragraph</p>", "<p>First paragraph</p>\n<p>Second paragraph</p>"},
+		{media.Builtin.MarkdownType, "<p>FOOO</p>\n<p>First paragraph</p>", "", "<p>First paragraph</p>", "<p>First paragraph</p>"},
+		{media.Builtin.MarkdownType, "<p>First paragraph</p><p>Second paragraphFOOO</p><p>Third paragraph</p>", "<p>First paragraph</p><p>Second paragraph</p>", "<p>Third paragraph</p>", "<p>First paragraph</p><p>Second paragraph</p><p>Third paragraph</p>"},
+		{media.Builtin.MarkdownType, "<p>这是中文，全中文FOOO</p><p>a这是中文，全中文</p>", "<p>这是中文，全中文</p>", "<p>a这是中文，全中文</p>", "<p>这是中文，全中文</p><p>a这是中文，全中文</p>"},
+		{media.Builtin.MarkdownType, `<p>a <strong>b</strong>` + "\v" + ` c</p>` + "\n<p>FOOO</p>", "<p>a <strong>b</strong>\v c</p>", "", "<p>a <strong>b</strong>\v c</p>"},
+
+		{media.Builtin.HTMLType, "<p>First paragraph</p>FOOO<p>Second paragraph</p>", "<p>First paragraph</p>", "<p>Second paragraph</p>", "<p>First paragraph</p><p>Second paragraph</p>"},
+
+		{media.Builtin.ReStructuredTextType, "<div class=\"document\">\n\n\n<p>This is summary.</p>\n<p>FOOO</p>\n<p>This is content.</p>\n</div>", "<div class=\"document\">\n\n\n<p>This is summary.</p>\n</div>", "<div class=\"document\"><p>This is content.</p>\n</div>", "<div class=\"document\">\n\n\n<p>This is summary.</p>\n<p>This is content.</p>\n</div>"},
+		{media.Builtin.ReStructuredTextType, "<div class=\"document\"><p>First paragraphFOOO</p><p>Second paragraph</p></div>", "<div class=\"document\"><p>First paragraph</p></div>", "<div class=\"document\"><p>Second paragraph</p></div>", `<div class="document"><p>First paragraph</p><p>Second paragraph</p></div>`},
+
+		{media.Builtin.AsciiDocType, "<div class=\"paragraph\"><p>Summary Next Line</p></div><div class=\"paragraph\"><p>FOOO</p></div><div class=\"paragraph\"><p>Some more text</p></div>", "<div class=\"paragraph\"><p>Summary Next Line</p></div>", "<div class=\"paragraph\"><p>Some more text</p></div>", "<div class=\"paragraph\"><p>Summary Next Line</p></div><div class=\"paragraph\"><p>Some more text</p></div>"},
+		{media.Builtin.AsciiDocType, "<div class=\"paragraph\">\n<p>Summary Next Line</p>\n</div>\n<div class=\"paragraph\">\n<p>FOOO</p>\n</div>\n<div class=\"paragraph\">\n<p>Some more text</p>\n</div>\n", "<div class=\"paragraph\">\n<p>Summary Next Line</p>\n</div>", "<div class=\"paragraph\">\n<p>Some more text</p>\n</div>", "<div class=\"paragraph\">\n<p>Summary Next Line</p>\n</div>\n<div class=\"paragraph\">\n<p>Some more text</p>\n</div>"},
+		{media.Builtin.AsciiDocType, "<div><p>FOOO</p></div><div><p>First paragraph</p></div>", "", "<div><p>First paragraph</p></div>", "<div><p>First paragraph</p></div>"},
+		{media.Builtin.AsciiDocType, "<div><p>First paragraphFOOO</p></div><div><p>Second paragraph</p></div>", "<div><p>First paragraph</p></div>", "<div><p>Second paragraph</p></div>", "<div><p>First paragraph</p></div><div><p>Second paragraph</p></div>"},
+	}
+
+	for i, test := range tests {
+		summary := ExtractSummaryFromHTMLWithDivider(test.mt, test.input, divider)
+		c.Assert(summary.Summary(), qt.Equals, test.expectSummary, qt.Commentf("Summary %d", i))
+		c.Assert(summary.ContentWithoutSummary(), qt.Equals, test.expectContentWithoutSummary, qt.Commentf("ContentWithoutSummary %d", i))
+		c.Assert(summary.Content(), qt.Equals, test.expectContent, qt.Commentf("Content %d", i))
+	}
+}
+
+func TestExpandDivider(t *testing.T) {
+	c := qt.New(t)
+
+	for i, test := range []struct {
+		input           string
+		divider         string
+		ptag            tagReStartEnd
+		expect          string
+		expectEndMarkup string
+	}{
+		{"<p>First paragraph</p>\n<p>FOOO</p>\n<p>Second paragraph</p>", "FOOO", startEndP, "<p>FOOO</p>\n", ""},
+		{"<div class=\"paragraph\">\n<p>FOOO</p>\n</div>", "FOOO", startEndDiv, "<div class=\"paragraph\">\n<p>FOOO</p>\n</div>", ""},
+		{"<div><p>FOOO</p></div><div><p>Second paragraph</p></div>", "FOOO", startEndDiv, "<div><p>FOOO</p></div>", ""},
+		{"<div><p>First paragraphFOOO</p></div><div><p>Second paragraph</p></div>", "FOOO", startEndDiv, "FOOO", "</p></div>"},
+		{"   <p> abc FOOO  </p>  ", "FOOO", startEndP, "FOOO", "  </p>"},
+		{"   <p>  FOOO  </p>  ", "FOOO", startEndP, "<p>  FOOO  </p>", ""},
+		{"   <p>\n  \nFOOO  </p>  ", "FOOO", startEndP, "<p>\n  \nFOOO  </p>", ""},
+		{"   <div>  FOOO  </div>  ", "FOOO", startEndDiv, "<div>  FOOO  </div>", ""},
+	} {
+
+		l := types.LowHigh[string]{Low: strings.Index(test.input, test.divider), High: strings.Index(test.input, test.divider) + len(test.divider)}
+		e, t := expandSummaryDivider(test.input, test.ptag, l)
+		c.Assert(test.input[e.Low:e.High], qt.Equals, test.expect, qt.Commentf("[%d] Test.expect %q", i, test.input))
+		c.Assert(test.input[t.Low:t.High], qt.Equals, test.expectEndMarkup, qt.Commentf("[%d] Test.expectEndMarkup %q", i, test.input))
+	}
+}
+
+func BenchmarkSummaryFromHTML(b *testing.B) {
+	b.StopTimer()
+	input := "<p>First paragraph</p><p>Second paragraph</p>"
+	b.StartTimer()
+	for i := 0; i < b.N; i++ {
+		summary := ExtractSummaryFromHTML(media.Builtin.MarkdownType, input, 2, false)
+		if s := summary.Content(); s != input {
+			b.Fatalf("unexpected content: %q", s)
+		}
+		if s := summary.ContentWithoutSummary(); s != "<p>Second paragraph</p>" {
+			b.Fatalf("unexpected content without summary: %q", s)
+		}
+		if s := summary.Summary(); s != "<p>First paragraph</p>" {
+			b.Fatalf("unexpected summary: %q", s)
+		}
+	}
+}
+
+func BenchmarkSummaryFromHTMLWithDivider(b *testing.B) {
+	b.StopTimer()
+	input := "<p>First paragraph</p><p>FOOO</p><p>Second paragraph</p>"
+	b.StartTimer()
+	for i := 0; i < b.N; i++ {
+		summary := ExtractSummaryFromHTMLWithDivider(media.Builtin.MarkdownType, input, "FOOO")
+		if s := summary.Content(); s != "<p>First paragraph</p><p>Second paragraph</p>" {
+			b.Fatalf("unexpected content: %q", s)
+		}
+		if s := summary.ContentWithoutSummary(); s != "<p>Second paragraph</p>" {
+			b.Fatalf("unexpected content without summary: %q", s)
+		}
+		if s := summary.Summary(); s != "<p>First paragraph</p>" {
+			b.Fatalf("unexpected summary: %q", s)
+		}
+	}
+}
diff --git a/resources/page/testhelpers_test.go b/resources/page/testhelpers_test.go
index cedbc74e960..9eefeeea410 100644
--- a/resources/page/testhelpers_test.go
+++ b/resources/page/testhelpers_test.go
@@ -149,6 +149,10 @@ func (p *testPage) Content(context.Context) (any, error) {
 	panic("testpage: not implemented")
 }
 
+func (p *testPage) Markup(...any) Markup {
+	panic("testpage: not implemented")
+}
+
 func (p *testPage) ContentBaseName() string {
 	panic("testpage: not implemented")
 }
@@ -177,6 +181,10 @@ func (p *testPage) Description() string {
 	return ""
 }
 
+func (p *testPage) ContentWithoutSummary(ctx context.Context) (template.HTML, error) {
+	return "", nil
+}
+
 func (p *testPage) Dir() string {
 	panic("testpage: not implemented")
 }