Add Page.Contents with scope support

Note that this also adds a new `.ContentWithoutSummary` method, and to do that we had to unify the different summary types:

Both `auto` and `manual` now returns HTML. Before this commit, `auto` would return plain text. This could be considered to be a slightly breaking change, but for the better: Now you can treat the `.Summary` the same without thinking about where it comes from, and if you want plain text, pipe it into `{{ .Summary | plainify }}`.

Fixes #8680
Fixes #12761
Fixes #12778
Fixes #716
This commit is contained in:
Bjørn Erik Pedersen 2024-08-13 15:49:56 +02:00
parent 2b5c335e93
commit 37609262dc
22 changed files with 1614 additions and 858 deletions

View file

@ -14,6 +14,7 @@
package hugo package hugo
import ( import (
"context"
"fmt" "fmt"
"html/template" "html/template"
"os" "os"
@ -29,6 +30,7 @@ import (
"github.com/mitchellh/mapstructure" "github.com/mitchellh/mapstructure"
"github.com/bep/godartsass/v2" "github.com/bep/godartsass/v2"
"github.com/gohugoio/hugo/common/hcontext"
"github.com/gohugoio/hugo/common/hexec" "github.com/gohugoio/hugo/common/hexec"
"github.com/gohugoio/hugo/common/loggers" "github.com/gohugoio/hugo/common/loggers"
"github.com/gohugoio/hugo/hugofs/files" "github.com/gohugoio/hugo/hugofs/files"
@ -69,6 +71,9 @@ type HugoInfo struct {
conf ConfigProvider conf ConfigProvider
deps []*Dependency deps []*Dependency
// Context gives access to some of the context scoped variables.
Context Context
} }
// Version returns the current version as a comparable version string. // Version returns the current version as a comparable version string.
@ -127,6 +132,26 @@ func (i HugoInfo) IsMultilingual() bool {
return i.conf.IsMultilingual() 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. // ConfigProvider represents the config options that are relevant for HugoInfo.
type ConfigProvider interface { type ConfigProvider interface {
Environment() string Environment() string

View file

@ -14,6 +14,7 @@
package hugo package hugo
import ( import (
"context"
"fmt" "fmt"
"testing" "testing"
@ -64,6 +65,19 @@ func TestDeprecationLogLevelFromVersion(t *testing.T) {
c.Assert(deprecationLogLevelFromVersion(ver.String()), qt.Equals, logg.LevelError) 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 { type testConfig struct {
environment string environment string
running bool running bool

View file

@ -153,7 +153,7 @@ func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) {
} else { } else {
high = len(p.s) 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 { if len(p.identifiers) == 0 {
p.identifiers = append(p.identifiers, id) p.identifiers = append(p.identifiers, id)
} else if len(p.identifiers) == 1 { } else if len(p.identifiers) == 1 {
@ -260,7 +260,7 @@ type Path struct {
component string component string
bundleType PathType bundleType PathType
identifiers []types.LowHigh identifiers []types.LowHigh[string]
posIdentifierLanguage int posIdentifierLanguage int
disabled bool disabled bool

View file

@ -107,12 +107,20 @@ func Unwrapv(v any) any {
return v return v
} }
// LowHigh is typically used to represent a slice boundary. // LowHigh represents a byte or slice boundary.
type LowHigh struct { type LowHigh[S ~[]byte | string] struct {
Low int Low int
High 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. // This is only used for debugging purposes.
var InvocationCounter atomic.Int64 var InvocationCounter atomic.Int64

View file

@ -27,3 +27,25 @@ func TestKeyValues(t *testing.T) {
c.Assert(kv.KeyString(), qt.Equals, "key") c.Assert(kv.KeyString(), qt.Equals, "key")
c.Assert(kv.Values, qt.DeepEquals, []any{"a1", "a2"}) 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"))
}

View file

@ -22,7 +22,6 @@ import (
"html/template" "html/template"
"strings" "strings"
"unicode" "unicode"
"unicode/utf8"
"github.com/gohugoio/hugo/common/hexec" "github.com/gohugoio/hugo/common/hexec"
"github.com/gohugoio/hugo/common/loggers" "github.com/gohugoio/hugo/common/loggers"
@ -165,75 +164,6 @@ func TotalWords(s string) int {
return n 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 // 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 // tag is present only once with the input, and (b) the opening and closing
// tags wrap the input after white space removal. // tags wrap the input after white space removal.
@ -256,7 +186,3 @@ func (c *ContentSpec) TrimShortHTML(input []byte, markup string) []byte {
} }
return input return input
} }
func isEndOfSentence(r rune) bool {
return r == '.' || r == '?' || r == '!' || r == '"' || r == '\n'
}

View file

@ -20,7 +20,6 @@ import (
"testing" "testing"
qt "github.com/frankban/quicktest" qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/config"
"github.com/gohugoio/hugo/helpers" "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")) 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) { func TestExtractTOCNormalContent(t *testing.T) {
content := []byte("<nav>\n<ul>\nTOC<li><a href=\"#") content := []byte("<nav>\n<ul>\nTOC<li><a href=\"#")

View file

@ -61,6 +61,7 @@ var (
pageTypesProvider = resource.NewResourceTypesProvider(media.Builtin.OctetType, pageResourceType) pageTypesProvider = resource.NewResourceTypesProvider(media.Builtin.OctetType, pageResourceType)
nopPageOutput = &pageOutput{ nopPageOutput = &pageOutput{
pagePerOutputProviders: nopPagePerOutput, pagePerOutputProviders: nopPagePerOutput,
MarkupProvider: page.NopPage,
ContentProvider: page.NopPage, ContentProvider: page.NopPage,
} }
) )
@ -213,11 +214,8 @@ func (p *pageHeadingsFiltered) page() page.Page {
// For internal use by the related content feature. // For internal use by the related content feature.
func (p *pageState) ApplyFilterToHeadings(ctx context.Context, fn func(*tableofcontents.Heading) bool) related.Document { func (p *pageState) ApplyFilterToHeadings(ctx context.Context, fn func(*tableofcontents.Heading) bool) related.Document {
r, err := p.m.content.contentToC(ctx, p.pageOutput.pco) fragments := p.pageOutput.pco.c().Fragments(ctx)
if err != nil { headings := fragments.Headings.FilterBy(fn)
panic(err)
}
headings := r.tableOfContents.Headings.FilterBy(fn)
return &pageHeadingsFiltered{ return &pageHeadingsFiltered{
pageState: p, pageState: p,
headings: headings, headings: headings,
@ -719,6 +717,7 @@ func (p *pageState) shiftToOutputFormat(isRenderingSite bool, idx int) error {
}) })
p.pageOutput.contentRenderer = lcp p.pageOutput.contentRenderer = lcp
p.pageOutput.ContentProvider = lcp p.pageOutput.ContentProvider = lcp
p.pageOutput.MarkupProvider = lcp
p.pageOutput.PageRenderProvider = lcp p.pageOutput.PageRenderProvider = lcp
p.pageOutput.TableOfContentsProvider = lcp p.pageOutput.TableOfContentsProvider = lcp
} }

View file

@ -14,7 +14,6 @@
package hugolib package hugolib
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@ -29,15 +28,23 @@ import (
"github.com/gohugoio/hugo/common/hcontext" "github.com/gohugoio/hugo/common/hcontext"
"github.com/gohugoio/hugo/common/herrors" "github.com/gohugoio/hugo/common/herrors"
"github.com/gohugoio/hugo/common/hugio" "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/helpers"
"github.com/gohugoio/hugo/identity" "github.com/gohugoio/hugo/identity"
"github.com/gohugoio/hugo/markup"
"github.com/gohugoio/hugo/markup/converter" "github.com/gohugoio/hugo/markup/converter"
"github.com/gohugoio/hugo/markup/goldmark/hugocontext"
"github.com/gohugoio/hugo/markup/tableofcontents" "github.com/gohugoio/hugo/markup/tableofcontents"
"github.com/gohugoio/hugo/parser/metadecoders" "github.com/gohugoio/hugo/parser/metadecoders"
"github.com/gohugoio/hugo/parser/pageparser" "github.com/gohugoio/hugo/parser/pageparser"
"github.com/gohugoio/hugo/resources" "github.com/gohugoio/hugo/resources"
"github.com/gohugoio/hugo/resources/page"
"github.com/gohugoio/hugo/resources/resource" "github.com/gohugoio/hugo/resources/resource"
"github.com/gohugoio/hugo/tpl" "github.com/gohugoio/hugo/tpl"
"github.com/mitchellh/mapstructure"
"github.com/spf13/cast"
) )
const ( const (
@ -45,8 +52,8 @@ const (
) )
var ( var (
internalSummaryDividerBaseBytes = []byte(internalSummaryDividerBase) internalSummaryDividerPreString = "\n\n" + internalSummaryDividerBase + "\n\n"
internalSummaryDividerPre = []byte("\n\n" + internalSummaryDividerBase + "\n\n") internalSummaryDividerPre = []byte(internalSummaryDividerPreString)
) )
type pageContentReplacement struct { type pageContentReplacement struct {
@ -130,6 +137,7 @@ func (m *pageMeta) newCachedContent(h *HugoSites, pi *contentParseInfo) (*cached
shortcodeState: newShortcodeHandler(filename, m.s), shortcodeState: newShortcodeHandler(filename, m.s),
pi: pi, pi: pi,
enableEmoji: m.s.conf.EnableEmoji, enableEmoji: m.s.conf.EnableEmoji,
scopes: maps.NewCache[string, *cachedContentScope](),
} }
source, err := c.pi.contentSource(m) source, err := c.pi.contentSource(m)
@ -155,6 +163,20 @@ type cachedContent struct {
pi *contentParseInfo pi *contentParseInfo
enableEmoji bool 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 { type contentParseInfo struct {
@ -171,9 +193,6 @@ type contentParseInfo struct {
// Whether the parsed content contains a summary separator. // Whether the parsed content contains a summary separator.
hasSummaryDivider bool hasSummaryDivider bool
// Whether there are more content after the summary divider.
summaryTruncated bool
// Returns the position in bytes after any front matter. // Returns the position in bytes after any front matter.
posMainContent int posMainContent int
@ -368,8 +387,6 @@ Loop:
} }
if item.IsNonWhitespace(source) { if item.IsNonWhitespace(source) {
rn.summaryTruncated = true
// Done // Done
return false return false
} }
@ -487,26 +504,28 @@ type contentTableOfContents struct {
} }
type contentSummary struct { type contentSummary struct {
content template.HTML content string
summary template.HTML contentWithoutSummary template.HTML
summaryTruncated bool summary page.Summary
} }
type contentPlainPlainWords struct { type contentPlainPlainWords struct {
plain string plain string
plainWords []string plainWords []string
summary template.HTML
summaryTruncated bool
wordCount int wordCount int
fuzzyWordCount int fuzzyWordCount int
readingTime 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) 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) versionv := c.version(cp)
v, err := c.pm.cacheContentRendered.GetOrCreate(key, func(string) (*resources.StaleValue[contentSummary], error) { 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.po.p.s.h.contentRenderCounter.Add(1)
cp.contentRendered = true cp.contentRendered.Store(true)
po := cp.po po := cp.po
ct, err := c.contentToC(ctx, cp) ct, err := c.contentToC(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
rs := &resources.StaleValue[contentSummary]{ rs, err := func() (*resources.StaleValue[contentSummary], error) {
StaleVersionFunc: func() uint32 { rs := &resources.StaleValue[contentSummary]{
return c.version(cp) - versionv StaleVersionFunc: func() uint32 {
}, return c.version(cp) - versionv
} },
if len(c.pi.itemsStep2) == 0 {
// Nothing to do.
return rs, nil
}
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 !ok { if len(c.pi.itemsStep2) == 0 {
return nil, errors.New("invalid state: astDoc is set but RenderContent returned false") // Nothing to do.
return rs, nil
} }
b = r.Bytes() var b []byte
} else { if ct.astDoc != nil {
// Copy the content to be rendered. // The content is parsed, but not rendered.
b = make([]byte, len(ct.contentToRender)) r, ok, err := po.contentRenderer.RenderContent(ctx, ct.contentToRender, ct.astDoc)
copy(b, ct.contentToRender)
}
// 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 { if err != nil {
return nil, err 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)))
}
b, err = expandShortcodeTokens(ctx, b, tokenHandler) if !ok {
if err != nil { return nil, errors.New("invalid state: astDoc is set but RenderContent returned false")
return nil, err }
}
if hasShortcodeVariants {
cp.po.p.pageOutputTemplateVariationsState.Add(1)
}
var result contentSummary // hasVariants bool b = r.Bytes()
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 { } else {
summary, content, err := splitUserDefinedSummaryAndContent(cp.po.p.m.pageConfig.Content.Markup, b) // Copy the content to be rendered.
if err != nil { b = make([]byte, len(ct.contentToRender))
cp.po.p.s.Log.Errorf("Failed to set user defined summary for page %q: %s", cp.po.p.pathOrTitle(), err) copy(b, ct.contentToRender)
} else {
b = content
result.summary = helpers.BytesToHTML(summary)
}
} }
result.summaryTruncated = c.pi.summaryTruncated
}
result.content = helpers.BytesToHTML(b)
rs.Value = result
return rs, nil // 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)))
}
b, err = expandShortcodeTokens(ctx, b, tokenHandler)
if err != nil {
return nil, err
}
if hasShortcodeVariants {
cp.po.p.pageOutputTemplateVariationsState.Add(1)
}
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)
}
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(),
}
result.contentWithoutSummary = template.HTML(summary.ContentWithoutSummary())
}
rs.Value = result
return rs, nil
}()
if err != nil {
return rs, cp.po.p.wrapError(err)
}
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,
}
}
return rs, err
}) })
if err != nil { if err != nil {
return contentSummary{}, cp.po.p.wrapError(err) return contentSummary{}, cp.po.p.wrapError(err)
@ -614,8 +657,8 @@ func (c *cachedContent) contentRendered(ctx context.Context, cp *pageContentOutp
return v.Value, nil return v.Value, nil
} }
func (c *cachedContent) mustContentToC(ctx context.Context, cp *pageContentOutput) contentTableOfContents { func (c *cachedContentScope) mustContentToC(ctx context.Context) contentTableOfContents {
ct, err := c.contentToC(ctx, cp) ct, err := c.contentToC(ctx)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -624,8 +667,9 @@ func (c *cachedContent) mustContentToC(ctx context.Context, cp *pageContentOutpu
var setGetContentCallbackInContext = hcontext.NewContextDispatcher[func(*pageContentOutput, contentTableOfContents)]("contentCallback") var setGetContentCallbackInContext = hcontext.NewContextDispatcher[func(*pageContentOutput, contentTableOfContents)]("contentCallback")
func (c *cachedContent) contentToC(ctx context.Context, cp *pageContentOutput) (contentTableOfContents, error) { func (c *cachedContentScope) contentToC(ctx context.Context) (contentTableOfContents, error) {
key := c.pi.sourceKey + "/" + cp.po.f.Name cp := c.pco
key := c.pi.sourceKey + "/" + c.keyScope(ctx)
versionv := c.version(cp) versionv := c.version(cp)
v, err := c.pm.contentTableOfContents.GetOrCreate(key, func(string) (*resources.StaleValue[contentTableOfContents], error) { 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) // Callback called from below (e.g. in .RenderString)
ctxCallback := func(cp2 *pageContentOutput, ct2 contentTableOfContents) { ctxCallback := func(cp2 *pageContentOutput, ct2 contentTableOfContents) {
cp.otherOutputs[cp2.po.p.pid] = cp2 cp.otherOutputs.Set(cp2.po.p.pid, cp2)
// Merge content placeholders // Merge content placeholders
for k, v := range ct2.contentPlaceholders { for k, v := range ct2.contentPlaceholders {
@ -749,8 +793,9 @@ func (c *cachedContent) version(cp *pageContentOutput) uint32 {
return c.StaleVersion() + cp.contentRenderedVersion return c.StaleVersion() + cp.contentRenderedVersion
} }
func (c *cachedContent) contentPlain(ctx context.Context, cp *pageContentOutput) (contentPlainPlainWords, error) { func (c *cachedContentScope) contentPlain(ctx context.Context) (contentPlainPlainWords, error) {
key := c.pi.sourceKey + "/" + cp.po.f.Name cp := c.pco
key := c.pi.sourceKey + "/" + c.keyScope(ctx)
versionv := c.version(cp) 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 { if err != nil {
return nil, err return nil, err
} }
@ -797,28 +842,6 @@ func (c *cachedContent) contentPlain(ctx context.Context, cp *pageContentOutput)
result.readingTime = (result.wordCount + 212) / 213 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 rs.Value = result
return rs, nil return rs, nil
@ -831,3 +854,332 @@ func (c *cachedContent) contentPlain(ctx context.Context, cp *pageContentOutput)
} }
return v.Value, nil 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
}

View file

@ -821,7 +821,7 @@ func (p *pageMeta) newContentConverter(ps *pageState, markup string) (converter.
// This prevents infinite recursion in some cases. // This prevents infinite recursion in some cases.
return doc 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 v.po.p
} }
return nil return nil

View file

@ -65,6 +65,7 @@ func newPageOutput(
p: ps, p: ps,
f: f, f: f,
pagePerOutputProviders: providers, pagePerOutputProviders: providers,
MarkupProvider: page.NopPage,
ContentProvider: page.NopPage, ContentProvider: page.NopPage,
PageRenderProvider: page.NopPage, PageRenderProvider: page.NopPage,
TableOfContentsProvider: page.NopPage, TableOfContentsProvider: page.NopPage,
@ -95,6 +96,7 @@ type pageOutput struct {
// output format. // output format.
contentRenderer page.ContentRenderer contentRenderer page.ContentRenderer
pagePerOutputProviders pagePerOutputProviders
page.MarkupProvider
page.ContentProvider page.ContentProvider
page.PageRenderProvider page.PageRenderProvider
page.TableOfContentsProvider page.TableOfContentsProvider
@ -119,7 +121,7 @@ func (po *pageOutput) isRendered() bool {
if po.renderState > 0 { if po.renderState > 0 {
return true return true
} }
if po.pco != nil && po.pco.contentRendered { if po.pco != nil && po.pco.contentRendered.Load() {
return true return true
} }
return false return false
@ -139,6 +141,7 @@ func (p *pageOutput) setContentProvider(cp *pageContentOutput) {
} }
p.contentRenderer = cp p.contentRenderer = cp
p.ContentProvider = cp p.ContentProvider = cp
p.MarkupProvider = cp
p.PageRenderProvider = cp p.PageRenderProvider = cp
p.TableOfContentsProvider = cp p.TableOfContentsProvider = cp
p.RenderShortcodesProvider = cp p.RenderShortcodesProvider = cp

View file

@ -21,18 +21,14 @@ import (
"html/template" "html/template"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"github.com/gohugoio/hugo/common/maps"
"github.com/gohugoio/hugo/common/text" "github.com/gohugoio/hugo/common/text"
"github.com/gohugoio/hugo/common/types/hstring"
"github.com/gohugoio/hugo/identity" "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/spf13/cast"
"github.com/gohugoio/hugo/markup/converter/hooks" "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/highlight/chromalexers"
"github.com/gohugoio/hugo/markup/tableofcontents" "github.com/gohugoio/hugo/markup/tableofcontents"
@ -41,7 +37,6 @@ import (
bp "github.com/gohugoio/hugo/bufferpool" bp "github.com/gohugoio/hugo/bufferpool"
"github.com/gohugoio/hugo/tpl" "github.com/gohugoio/hugo/tpl"
"github.com/gohugoio/hugo/helpers"
"github.com/gohugoio/hugo/output" "github.com/gohugoio/hugo/output"
"github.com/gohugoio/hugo/resources/page" "github.com/gohugoio/hugo/resources/page"
"github.com/gohugoio/hugo/resources/resource" "github.com/gohugoio/hugo/resources/resource"
@ -73,7 +68,7 @@ func newPageContentOutput(po *pageOutput) (*pageContentOutput, error) {
cp := &pageContentOutput{ cp := &pageContentOutput{
po: po, po: po,
renderHooks: &renderHooks{}, renderHooks: &renderHooks{},
otherOutputs: make(map[uint64]*pageContentOutput), otherOutputs: maps.NewCache[uint64, *pageContentOutput](),
} }
return cp, nil return cp, nil
} }
@ -89,10 +84,10 @@ type pageContentOutput struct {
// Other pages involved in rendering of this page, // Other pages involved in rendering of this page,
// typically included with .RenderShortcodes. // typically included with .RenderShortcodes.
otherOutputs map[uint64]*pageContentOutput otherOutputs *maps.Cache[uint64, *pageContentOutput]
contentRenderedVersion uint32 // Incremented on reset. contentRenderedVersion uint32 // Incremented on reset.
contentRendered bool // Set on content render. contentRendered atomic.Bool // Set on content render.
// Renders Markdown hooks. // Renders Markdown hooks.
renderHooks *renderHooks renderHooks *renderHooks
@ -107,294 +102,10 @@ func (pco *pageContentOutput) Reset() {
return return
} }
pco.contentRenderedVersion++ pco.contentRenderedVersion++
pco.contentRendered = false pco.contentRendered.Store(false)
pco.renderHooks = &renderHooks{} 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
}
ct, err := content.contentToC(ctx, pco)
if err != nil {
return "", err
}
var insertPlaceholders bool
var hasVariants bool
cb := setGetContentCallbackInContext.Get(ctx)
if cb != nil {
insertPlaceholders = true
}
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))
}
}
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(c, pco.po.p.pid)), nil
}
return helpers.BytesToHTML(c), nil
}
func (pco *pageContentOutput) Content(ctx context.Context) (any, error) {
r, err := pco.po.p.m.content.contentRendered(ctx, pco)
return r.content, err
}
func (pco *pageContentOutput) TableOfContents(ctx context.Context) template.HTML {
return pco.po.p.m.content.mustContentToC(ctx, pco).tableOfContentsHTML
}
func (p *pageContentOutput) Len(ctx context.Context) int {
return len(p.mustContentRendered(ctx).content)
}
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) mustContentPlain(ctx context.Context) contentPlainPlainWords {
r, err := pco.po.p.m.content.contentPlain(ctx, pco)
if err != nil {
pco.fail(err)
}
return r
}
func (pco *pageContentOutput) fail(err error) {
pco.po.p.s.h.FatalError(pco.po.p.wrapError(err))
}
func (pco *pageContentOutput) Plain(ctx context.Context) string {
return pco.mustContentPlain(ctx).plain
}
func (pco *pageContentOutput) PlainWords(ctx context.Context) []string {
return pco.mustContentPlain(ctx).plainWords
}
func (pco *pageContentOutput) ReadingTime(ctx context.Context) int {
return pco.mustContentPlain(ctx).readingTime
}
func (pco *pageContentOutput) WordCount(ctx context.Context) int {
return pco.mustContentPlain(ctx).wordCount
}
func (pco *pageContentOutput) FuzzyWordCount(ctx context.Context) int {
return pco.mustContentPlain(ctx).fuzzyWordCount
}
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)
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)
}
return template.HTML(string(rendered)), nil
}
func (pco *pageContentOutput) Render(ctx context.Context, layout ...string) (template.HTML, error) { func (pco *pageContentOutput) Render(ctx context.Context, layout ...string) (template.HTML, error) {
if len(layout) == 0 { if len(layout) == 0 {
return "", errors.New("no layout given") return "", errors.New("no layout given")
@ -416,6 +127,105 @@ func (pco *pageContentOutput) Render(ctx context.Context, layout ...string) (tem
return template.HTML(res), nil return template.HTML(res), nil
} }
func (pco *pageContentOutput) Fragments(ctx context.Context) *tableofcontents.Fragments {
return pco.c().Fragments(ctx)
}
func (pco *pageContentOutput) RenderShortcodes(ctx context.Context) (template.HTML, error) {
return pco.c().RenderShortcodes(ctx)
}
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)
}
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.c().Render(ctx)
if err != nil {
return nil, err
}
return r.Content(ctx)
}
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 (pco *pageContentOutput) TableOfContents(ctx context.Context) template.HTML {
return pco.c().(*cachedContentScope).fragmentsHTML(ctx)
}
func (pco *pageContentOutput) Len(ctx context.Context) int {
return pco.mustRender(ctx).Len(ctx)
}
func (pco *pageContentOutput) mustRender(ctx context.Context) page.Content {
c, err := pco.c().Render(ctx)
if err != nil {
pco.fail(err)
}
return c
}
func (pco *pageContentOutput) fail(err error) {
pco.po.p.s.h.FatalError(pco.po.p.wrapError(err))
}
func (pco *pageContentOutput) Plain(ctx context.Context) string {
return pco.mustRender(ctx).Plain(ctx)
}
func (pco *pageContentOutput) PlainWords(ctx context.Context) []string {
return pco.mustRender(ctx).PlainWords(ctx)
}
func (pco *pageContentOutput) ReadingTime(ctx context.Context) int {
return pco.mustRender(ctx).ReadingTime(ctx)
}
func (pco *pageContentOutput) WordCount(ctx context.Context) int {
return pco.mustRender(ctx).WordCount(ctx)
}
func (pco *pageContentOutput) FuzzyWordCount(ctx context.Context) int {
return pco.mustRender(ctx).FuzzyWordCount(ctx)
}
func (pco *pageContentOutput) Summary(ctx context.Context) template.HTML {
summary, err := pco.mustRender(ctx).Summary(ctx)
if err != nil {
pco.fail(err)
}
return summary.Text
}
func (pco *pageContentOutput) Truncated(ctx context.Context) bool {
summary, err := pco.mustRender(ctx).Summary(ctx)
if err != nil {
pco.fail(err)
}
return summary.Truncated
}
func (pco *pageContentOutput) RenderString(ctx context.Context, args ...any) (template.HTML, error) {
return pco.c().RenderString(ctx, args...)
}
func (pco *pageContentOutput) initRenderHooks() error { func (pco *pageContentOutput) initRenderHooks() error {
if pco == nil { if pco == nil {
return nil return nil
@ -660,65 +470,3 @@ func executeToString(ctx context.Context, h tpl.TemplateHandler, templ tpl.Templ
} }
return b.String(), nil 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
}

View file

@ -63,15 +63,6 @@ Summary Next Line
<!--more--> <!--more-->
Some more text Some more text
`
simplePageWithBlankSummary = `---
title: SimpleWithBlankSummary
---
<!--more-->
Some text.
` `
simplePageWithSummaryParameter = `--- 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) { 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) b := normalizeContent(summary)
if a != b { if a != b {
t.Fatalf("Page summary is:\n%q.\nExpected\n%q (%q)", a, b, msg) 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) 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) { func TestPageSummary(t *testing.T) {
t.Parallel() t.Parallel()
assertFunc := func(t *testing.T, ext string, pages page.Pages) { 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 // Source is not Asciidoctor- or RST-compatible so don't test them
if ext != "ad" && ext != "rst" { 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) 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") checkPageType(t, p, "page")
} }
@ -642,19 +614,6 @@ func TestPageWithDelimiter(t *testing.T) {
testAllMarkdownEnginesForPages(t, assertFunc, nil, simplePageWithSummaryDelimiter) 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) { func TestPageWithSummaryParameter(t *testing.T) {
t.Parallel() t.Parallel()
assertFunc := func(t *testing.T, ext string, pages page.Pages) { assertFunc := func(t *testing.T, ext string, pages page.Pages) {
@ -729,19 +688,6 @@ title: "empty"
b.AssertFileContent("public/empty/index.html", "! title") 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) { func TestTableOfContents(t *testing.T) {
c := qt.New(t) c := qt.New(t)
cfg, fs := newTestCfg() cfg, fs := newTestCfg()
@ -853,7 +799,7 @@ Summary: {{ .Summary }}|Truncated: {{ .Truncated }}|
Content: {{ .Content }}| Content: {{ .Content }}|
`).AssertFileContent("public/simple/index.html", `).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|", "Truncated: true|",
"Content: <p>This is <strong>summary</strong>.") "Content: <p>This is <strong>summary</strong>.")
} }
@ -1242,11 +1188,6 @@ func TestWordCountWithMainEnglishWithCJKRunes(t *testing.T) {
if p.WordCount(context.Background()) != 74 { if p.WordCount(context.Background()) != 74 {
t.Fatalf("[%s] incorrect word count, expected %v, got %v", ext, 74, p.WordCount(context.Background())) 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) testAllMarkdownEnginesForPages(t, assertFunc, settings, simplePageWithMainEnglishWithCJKRunes)
@ -1263,11 +1204,6 @@ func TestWordCountWithIsCJKLanguageFalse(t *testing.T) {
if p.WordCount(context.Background()) != 75 { 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())) 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) testAllMarkdownEnginesForPages(t, assertFunc, settings, simplePageWithIsCJKLanguageFalse)
@ -1485,42 +1421,6 @@ func TestChompBOM(t *testing.T) {
checkPageTitle(t, p, "Simple") 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 // https://github.com/gohugoio/hugo/issues/5381
func TestPageManualSummary(t *testing.T) { func TestPageManualSummary(t *testing.T) {
b := newTestSitesBuilder(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|") 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) { func TestScratch(t *testing.T) {
t.Parallel() t.Parallel()

View file

@ -65,6 +65,7 @@ var zeroShortcode = prerenderedShortcode{}
type pageForShortcode struct { type pageForShortcode struct {
page.PageWithoutContent page.PageWithoutContent
page.TableOfContentsProvider page.TableOfContentsProvider
page.MarkupProvider
page.ContentProvider page.ContentProvider
// We need to replace it after we have rendered it, so provide a // 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{ return &pageForShortcode{
PageWithoutContent: p, PageWithoutContent: p,
TableOfContentsProvider: p, TableOfContentsProvider: p,
MarkupProvider: page.NopPage,
ContentProvider: page.NopPage, ContentProvider: page.NopPage,
toc: template.HTML(tocShortcodePlaceholder), toc: template.HTML(tocShortcodePlaceholder),
p: p, p: p,
@ -105,6 +107,7 @@ var _ types.Unwrapper = (*pageForRenderHooks)(nil)
type pageForRenderHooks struct { type pageForRenderHooks struct {
page.PageWithoutContent page.PageWithoutContent
page.TableOfContentsProvider page.TableOfContentsProvider
page.MarkupProvider
page.ContentProvider page.ContentProvider
p *pageState p *pageState
} }
@ -112,6 +115,7 @@ type pageForRenderHooks struct {
func newPageForRenderHook(p *pageState) page.Page { func newPageForRenderHook(p *pageState) page.Page {
return &pageForRenderHooks{ return &pageForRenderHooks{
PageWithoutContent: p, PageWithoutContent: p,
MarkupProvider: page.NopPage,
ContentProvider: page.NopPage, ContentProvider: page.NopPage,
TableOfContentsProvider: p, TableOfContentsProvider: p,
p: p, p: p,

View file

@ -756,12 +756,15 @@ title: "Hugo Rocks!"
func TestShortcodeParams(t *testing.T) { func TestShortcodeParams(t *testing.T) {
t.Parallel() t.Parallel()
c := qt.New(t)
builder := newTestSitesBuilder(t).WithSimpleConfigFile() files := `
-- hugo.toml --
builder.WithContent("page.md", `--- 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!" title: "Hugo Rocks!"
summary: "Foo"
--- ---
# doc # 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 named: {{< hello b1=true b2=false i1=33 f1=3.14 >}}
types string: {{< hello "true" trues "33" "3.14" >}} types string: {{< hello "true" trues "33" "3.14" >}}
escaped quoute: {{< hello "hello \"world\"." >}} escaped quoute: {{< hello "hello \"world\"." >}}
-- layouts/_default/single.html --
Content: {{ .Content }}|
`
b := Test(t, files)
`).WithTemplatesAdded( b.AssertFileContent("public/page/index.html",
"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",
"types positional: - 0: true (bool) - 1: false (bool) - 2: 33 (int) - 3: 3.14 (float64)", "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) ", "types string: - 0: true (string) - 1: trues (string) - 2: 33 (string) - 3: 3.14 (string) ",
"hello &#34;world&#34;. (string)", "hello &#34;world&#34;. (string)",
) )

View file

@ -74,10 +74,17 @@ type ChildCareProvider interface {
Resources() resource.Resources Resources() resource.Resources
} }
type MarkupProvider interface {
Markup(opts ...any) Markup
}
// ContentProvider provides the content related values for a Page. // ContentProvider provides the content related values for a Page.
type ContentProvider interface { type ContentProvider interface {
Content(context.Context) (any, error) 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 returns the Page Content stripped of HTML markup.
Plain(context.Context) string 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. // Page is the core interface in Hugo and what you get as the top level data context in your templates.
type Page interface { type Page interface {
MarkupProvider
ContentProvider ContentProvider
TableOfContentsProvider TableOfContentsProvider
PageWithoutContent PageWithoutContent
@ -260,7 +268,7 @@ type PageMetaInternalProvider interface {
type PageRenderProvider interface { type PageRenderProvider interface {
// Render renders the given layout with this Page as context. // Render renders the given layout with this Page as context.
Render(ctx context.Context, layout ...string) (template.HTML, error) 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. // for this Page.
// It takes an optional map as a second argument: // It takes an optional map as a second argument:
// //

View file

@ -35,6 +35,7 @@ type OutputFormatContentProvider interface {
// OutputFormatPageContentProvider holds the exported methods from Page that are "outputFormat aware". // OutputFormatPageContentProvider holds the exported methods from Page that are "outputFormat aware".
type OutputFormatPageContentProvider interface { type OutputFormatPageContentProvider interface {
MarkupProvider
ContentProvider ContentProvider
TableOfContentsProvider TableOfContentsProvider
PageRenderProvider PageRenderProvider
@ -74,6 +75,11 @@ func (lcp *LazyContentProvider) Reset() {
lcp.init.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 { func (lcp *LazyContentProvider) TableOfContents(ctx context.Context) template.HTML {
lcp.init.Do(ctx) lcp.init.Do(ctx)
return lcp.cp.TableOfContents(ctx) return lcp.cp.TableOfContents(ctx)
@ -89,6 +95,11 @@ func (lcp *LazyContentProvider) Content(ctx context.Context) (any, error) {
return lcp.cp.Content(ctx) 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 { func (lcp *LazyContentProvider) Plain(ctx context.Context) string {
lcp.init.Do(ctx) lcp.init.Do(ctx)
return lcp.cp.Plain(ctx) return lcp.cp.Plain(ctx)

View file

@ -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
}

View file

@ -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|",
)
}

View file

@ -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)
}
}
}

View file

@ -44,6 +44,8 @@ import (
var ( var (
NopPage Page = new(nopPage) NopPage Page = new(nopPage)
NopContentRenderer ContentRenderer = new(nopContentRenderer) NopContentRenderer ContentRenderer = new(nopContentRenderer)
NopMarkup Markup = new(nopMarkup)
NopContent Content = new(nopContent)
NopCPageContentRenderer = struct { NopCPageContentRenderer = struct {
OutputFormatPageContentProvider OutputFormatPageContentProvider
ContentRenderer ContentRenderer
@ -109,10 +111,18 @@ func (p *nopPage) BundleType() string {
return "" return ""
} }
func (p *nopPage) Markup(...any) Markup {
return NopMarkup
}
func (p *nopPage) Content(context.Context) (any, error) { func (p *nopPage) Content(context.Context) (any, error) {
return "", nil return "", nil
} }
func (p *nopPage) ContentWithoutSummary(ctx context.Context) (template.HTML, error) {
return "", nil
}
func (p *nopPage) ContentBaseName() string { func (p *nopPage) ContentBaseName() string {
return "" 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) { func (r *nopContentRenderer) RenderContent(ctx context.Context, content []byte, doc any) (converter.ResultRender, bool, error) {
return nil, false, nil 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
}

View file

@ -149,6 +149,10 @@ func (p *testPage) Content(context.Context) (any, error) {
panic("testpage: not implemented") panic("testpage: not implemented")
} }
func (p *testPage) Markup(...any) Markup {
panic("testpage: not implemented")
}
func (p *testPage) ContentBaseName() string { func (p *testPage) ContentBaseName() string {
panic("testpage: not implemented") panic("testpage: not implemented")
} }
@ -177,6 +181,10 @@ func (p *testPage) Description() string {
return "" return ""
} }
func (p *testPage) ContentWithoutSummary(ctx context.Context) (template.HTML, error) {
return "", nil
}
func (p *testPage) Dir() string { func (p *testPage) Dir() string {
panic("testpage: not implemented") panic("testpage: not implemented")
} }