From fe6c323c51f3f5da2520bc7916d6612196fe8942 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Tue, 13 Aug 2024 15:49:56 +0200 Subject: [PATCH] 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 --- common/hugo/hugo.go | 25 + common/hugo/hugo_test.go | 14 + common/paths/pathparser.go | 4 +- common/types/types.go | 12 +- common/types/types_test.go | 22 + helpers/content.go | 74 --- helpers/content_test.go | 79 --- hugolib/page.go | 9 +- hugolib/page__content.go | 578 ++++++++++++++---- hugolib/page__meta.go | 2 +- hugolib/page__output.go | 5 +- hugolib/page__per_output.go | 392 +++--------- hugolib/page_test.go | 204 +------ hugolib/shortcode_page.go | 4 + hugolib/shortcode_test.go | 31 +- resources/page/page.go | 10 +- resources/page/page_lazy_contentprovider.go | 11 + resources/page/page_markup.go | 344 +++++++++++ .../page/page_markup_integration_test.go | 337 ++++++++++ resources/page/page_markup_test.go | 151 +++++ resources/page/page_nop.go | 76 +++ resources/page/testhelpers_test.go | 8 + 22 files changed, 1574 insertions(+), 818 deletions(-) create mode 100644 resources/page/page_markup.go create mode 100644 resources/page/page_markup_integration_test.go create mode 100644 resources/page/page_markup_test.go diff --git a/common/hugo/hugo.go b/common/hugo/hugo.go index 0589ac9a36d..09ab8717f9a 100644 --- a/common/hugo/hugo.go +++ b/common/hugo/hugo.go @@ -14,6 +14,7 @@ package hugo import ( + "context" "fmt" "html/template" "os" @@ -29,6 +30,7 @@ import ( "github.com/mitchellh/mapstructure" "github.com/bep/godartsass/v2" + "github.com/gohugoio/hugo/common/hcontext" "github.com/gohugoio/hugo/common/hexec" "github.com/gohugoio/hugo/common/loggers" "github.com/gohugoio/hugo/hugofs/files" @@ -69,6 +71,9 @@ type HugoInfo struct { conf ConfigProvider deps []*Dependency + + // Context gives access to some of the context scoped variables. + Context Context } // Version returns the current version as a comparable version string. @@ -127,6 +132,26 @@ func (i HugoInfo) IsMultilingual() bool { return i.conf.IsMultilingual() } +type contextKey string + +var markupScope = hcontext.NewContextDispatcher[string](contextKey("markupScope")) + +type Context struct{} + +func (c Context) MarkupScope(ctx context.Context) string { + return GetMarkupScope(ctx) +} + +// SetMarkupScope sets the markup scope in the context. +func SetMarkupScope(ctx context.Context, s string) context.Context { + return markupScope.Set(ctx, s) +} + +// GetMarkupScope gets the markup scope from the context. +func GetMarkupScope(ctx context.Context) string { + return markupScope.Get(ctx) +} + // ConfigProvider represents the config options that are relevant for HugoInfo. type ConfigProvider interface { Environment() string diff --git a/common/hugo/hugo_test.go b/common/hugo/hugo_test.go index 6e8b2620e4a..241d8c0ae80 100644 --- a/common/hugo/hugo_test.go +++ b/common/hugo/hugo_test.go @@ -14,6 +14,7 @@ package hugo import ( + "context" "fmt" "testing" @@ -64,6 +65,19 @@ func TestDeprecationLogLevelFromVersion(t *testing.T) { c.Assert(deprecationLogLevelFromVersion(ver.String()), qt.Equals, logg.LevelError) } +func TestMarkupScope(t *testing.T) { + c := qt.New(t) + + conf := testConfig{environment: "production", workingDir: "/mywork", running: false} + info := NewInfo(conf, nil) + + ctx := context.Background() + + ctx = SetMarkupScope(ctx, "foo") + + c.Assert(info.Context.MarkupScope(ctx), qt.Equals, "foo") +} + type testConfig struct { environment string running bool diff --git a/common/paths/pathparser.go b/common/paths/pathparser.go index 5fa798fb057..94329fe7a67 100644 --- a/common/paths/pathparser.go +++ b/common/paths/pathparser.go @@ -153,7 +153,7 @@ func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) { } else { high = len(p.s) } - id := types.LowHigh{Low: i + 1, High: high} + id := types.LowHigh[string]{Low: i + 1, High: high} if len(p.identifiers) == 0 { p.identifiers = append(p.identifiers, id) } else if len(p.identifiers) == 1 { @@ -260,7 +260,7 @@ type Path struct { component string bundleType PathType - identifiers []types.LowHigh + identifiers []types.LowHigh[string] posIdentifierLanguage int disabled bool diff --git a/common/types/types.go b/common/types/types.go index 322dfe592b0..d32391a88b7 100644 --- a/common/types/types.go +++ b/common/types/types.go @@ -107,12 +107,20 @@ func Unwrapv(v any) any { return v } -// LowHigh is typically used to represent a slice boundary. -type LowHigh struct { +// LowHigh represents a byte or slice boundary. +type LowHigh[S ~[]byte | string] struct { Low int High int } +func (l LowHigh[S]) IsZero() bool { + return l.Low < 0 || (l.Low == 0 && l.High == 0) +} + +func (l LowHigh[S]) Value(source S) S { + return source[l.Low:l.High] +} + // This is only used for debugging purposes. var InvocationCounter atomic.Int64 diff --git a/common/types/types_test.go b/common/types/types_test.go index 6f13ae834c1..7957330477a 100644 --- a/common/types/types_test.go +++ b/common/types/types_test.go @@ -27,3 +27,25 @@ func TestKeyValues(t *testing.T) { c.Assert(kv.KeyString(), qt.Equals, "key") c.Assert(kv.Values, qt.DeepEquals, []any{"a1", "a2"}) } + +func TestLowHigh(t *testing.T) { + c := qt.New(t) + + lh := LowHigh[string]{ + Low: 2, + High: 10, + } + + s := "abcdefghijklmnopqrstuvwxyz" + c.Assert(lh.IsZero(), qt.IsFalse) + c.Assert(lh.Value(s), qt.Equals, "cdefghij") + + lhb := LowHigh[[]byte]{ + Low: 2, + High: 10, + } + + sb := []byte(s) + c.Assert(lhb.IsZero(), qt.IsFalse) + c.Assert(lhb.Value(sb), qt.DeepEquals, []byte("cdefghij")) +} diff --git a/helpers/content.go b/helpers/content.go index 49283d52631..9d74a3d3191 100644 --- a/helpers/content.go +++ b/helpers/content.go @@ -22,7 +22,6 @@ import ( "html/template" "strings" "unicode" - "unicode/utf8" "github.com/gohugoio/hugo/common/hexec" "github.com/gohugoio/hugo/common/loggers" @@ -165,75 +164,6 @@ func TotalWords(s string) int { return n } -// TruncateWordsByRune truncates words by runes. -func (c *ContentSpec) TruncateWordsByRune(in []string) (string, bool) { - words := make([]string, len(in)) - copy(words, in) - - count := 0 - for index, word := range words { - if count >= c.Cfg.SummaryLength() { - return strings.Join(words[:index], " "), true - } - runeCount := utf8.RuneCountInString(word) - if len(word) == runeCount { - count++ - } else if count+runeCount < c.Cfg.SummaryLength() { - count += runeCount - } else { - for ri := range word { - if count >= c.Cfg.SummaryLength() { - truncatedWords := append(words[:index], word[:ri]) - return strings.Join(truncatedWords, " "), true - } - count++ - } - } - } - - return strings.Join(words, " "), false -} - -// TruncateWordsToWholeSentence takes content and truncates to whole sentence -// limited by max number of words. It also returns whether it is truncated. -func (c *ContentSpec) TruncateWordsToWholeSentence(s string) (string, bool) { - var ( - wordCount = 0 - lastWordIndex = -1 - ) - - for i, r := range s { - if unicode.IsSpace(r) { - wordCount++ - lastWordIndex = i - - if wordCount >= c.Cfg.SummaryLength() { - break - } - - } - } - - if lastWordIndex == -1 { - return s, false - } - - endIndex := -1 - - for j, r := range s[lastWordIndex:] { - if isEndOfSentence(r) { - endIndex = j + lastWordIndex + utf8.RuneLen(r) - break - } - } - - if endIndex == -1 { - return s, false - } - - return strings.TrimSpace(s[:endIndex]), endIndex < len(s) -} - // TrimShortHTML removes the outer tags from HTML input where (a) the opening // tag is present only once with the input, and (b) the opening and closing // tags wrap the input after white space removal. @@ -256,7 +186,3 @@ func (c *ContentSpec) TrimShortHTML(input []byte, markup string) []byte { } return input } - -func isEndOfSentence(r rune) bool { - return r == '.' || r == '?' || r == '!' || r == '"' || r == '\n' -} diff --git a/helpers/content_test.go b/helpers/content_test.go index 22d4681910f..dd7c5a4c569 100644 --- a/helpers/content_test.go +++ b/helpers/content_test.go @@ -20,7 +20,6 @@ import ( "testing" qt "github.com/frankban/quicktest" - "github.com/gohugoio/hugo/config" "github.com/gohugoio/hugo/helpers" ) @@ -66,84 +65,6 @@ func TestBytesToHTML(t *testing.T) { c.Assert(helpers.BytesToHTML([]byte("dobedobedo")), qt.Equals, template.HTML("dobedobedo")) } -var benchmarkTruncateString = strings.Repeat("This is a sentence about nothing.", 20) - -func BenchmarkTestTruncateWordsToWholeSentence(b *testing.B) { - c := newTestContentSpec(nil) - b.ResetTimer() - for i := 0; i < b.N; i++ { - c.TruncateWordsToWholeSentence(benchmarkTruncateString) - } -} - -func TestTruncateWordsToWholeSentence(t *testing.T) { - type test struct { - input, expected string - max int - truncated bool - } - data := []test{ - {"a b c", "a b c", 12, false}, - {"a b c", "a b c", 3, false}, - {"a", "a", 1, false}, - {"This is a sentence.", "This is a sentence.", 5, false}, - {"This is also a sentence!", "This is also a sentence!", 1, false}, - {"To be. Or not to be. That's the question.", "To be.", 1, true}, - {" \nThis is not a sentence\nAnd this is another", "This is not a sentence", 4, true}, - {"", "", 10, false}, - {"This... is a more difficult test?", "This... is a more difficult test?", 1, false}, - } - for i, d := range data { - cfg := config.New() - cfg.Set("summaryLength", d.max) - c := newTestContentSpec(cfg) - output, truncated := c.TruncateWordsToWholeSentence(d.input) - if d.expected != output { - t.Errorf("Test %d failed. Expected %q got %q", i, d.expected, output) - } - - if d.truncated != truncated { - t.Errorf("Test %d failed. Expected truncated=%t got %t", i, d.truncated, truncated) - } - } -} - -func TestTruncateWordsByRune(t *testing.T) { - type test struct { - input, expected string - max int - truncated bool - } - data := []test{ - {"", "", 1, false}, - {"a b c", "a b c", 12, false}, - {"a b c", "a b c", 3, false}, - {"a", "a", 1, false}, - {"Hello 中国", "", 0, true}, - {"这是中文,全中文。", "这是中文,", 5, true}, - {"Hello 中国", "Hello 中", 2, true}, - {"Hello 中国", "Hello 中国", 3, false}, - {"Hello中国 Good 好的", "Hello中国 Good 好", 9, true}, - {"This is a sentence.", "This is", 2, true}, - {"This is also a sentence!", "This", 1, true}, - {"To be. Or not to be. That's the question.", "To be. Or not", 4, true}, - {" \nThis is not a sentence\n ", "This is not", 3, true}, - } - for i, d := range data { - cfg := config.New() - cfg.Set("summaryLength", d.max) - c := newTestContentSpec(cfg) - output, truncated := c.TruncateWordsByRune(strings.Fields(d.input)) - if d.expected != output { - t.Errorf("Test %d failed. Expected %q got %q", i, d.expected, output) - } - - if d.truncated != truncated { - t.Errorf("Test %d failed. Expected truncated=%t got %t", i, d.truncated, truncated) - } - } -} - func TestExtractTOCNormalContent(t *testing.T) { content := []byte("