diff options
Diffstat (limited to 'hugolib/segments')
-rw-r--r-- | hugolib/segments/segments.go | 257 | ||||
-rw-r--r-- | hugolib/segments/segments_integration_test.go | 76 | ||||
-rw-r--r-- | hugolib/segments/segments_test.go | 115 |
3 files changed, 448 insertions, 0 deletions
diff --git a/hugolib/segments/segments.go b/hugolib/segments/segments.go new file mode 100644 index 000000000..8f7c18121 --- /dev/null +++ b/hugolib/segments/segments.go @@ -0,0 +1,257 @@ +// 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 segments + +import ( + "fmt" + + "github.com/gobwas/glob" + "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/predicate" + "github.com/gohugoio/hugo/config" + hglob "github.com/gohugoio/hugo/hugofs/glob" + "github.com/mitchellh/mapstructure" +) + +// Segments is a collection of named segments. +type Segments struct { + s map[string]excludeInclude +} + +type excludeInclude struct { + exclude predicate.P[SegmentMatcherFields] + include predicate.P[SegmentMatcherFields] +} + +// ShouldExcludeCoarse returns whether the given fields should be excluded. +// This is used for the coarser grained checks, e.g. language and output format. +// Note that ShouldExcludeCoarse(fields) == ShouldExcludeFine(fields) may +// not always be true, but ShouldExcludeCoarse(fields) == true == ShouldExcludeFine(fields) +// will always be truthful. +func (e excludeInclude) ShouldExcludeCoarse(fields SegmentMatcherFields) bool { + return e.exclude != nil && e.exclude(fields) +} + +// ShouldExcludeFine returns whether the given fields should be excluded. +// This is used for the finer grained checks, e.g. on invididual pages. +func (e excludeInclude) ShouldExcludeFine(fields SegmentMatcherFields) bool { + if e.exclude != nil && e.exclude(fields) { + return true + } + return e.include != nil && !e.include(fields) +} + +type SegmentFilter interface { + // ShouldExcludeCoarse returns whether the given fields should be excluded on a coarse level. + ShouldExcludeCoarse(SegmentMatcherFields) bool + + // ShouldExcludeFine returns whether the given fields should be excluded on a fine level. + ShouldExcludeFine(SegmentMatcherFields) bool +} + +type segmentFilter struct { + coarse predicate.P[SegmentMatcherFields] + fine predicate.P[SegmentMatcherFields] +} + +func (f segmentFilter) ShouldExcludeCoarse(field SegmentMatcherFields) bool { + return f.coarse(field) +} + +func (f segmentFilter) ShouldExcludeFine(fields SegmentMatcherFields) bool { + return f.fine(fields) +} + +var ( + matchAll = func(SegmentMatcherFields) bool { return true } + matchNothing = func(SegmentMatcherFields) bool { return false } +) + +// Get returns a SegmentFilter for the given segments. +func (sms Segments) Get(onNotFound func(s string), ss ...string) SegmentFilter { + if ss == nil { + return segmentFilter{coarse: matchNothing, fine: matchNothing} + } + var sf segmentFilter + for _, s := range ss { + if seg, ok := sms.s[s]; ok { + if sf.coarse == nil { + sf.coarse = seg.ShouldExcludeCoarse + } else { + sf.coarse = sf.coarse.Or(seg.ShouldExcludeCoarse) + } + if sf.fine == nil { + sf.fine = seg.ShouldExcludeFine + } else { + sf.fine = sf.fine.Or(seg.ShouldExcludeFine) + } + } else if onNotFound != nil { + onNotFound(s) + } + } + + if sf.coarse == nil { + sf.coarse = matchAll + } + if sf.fine == nil { + sf.fine = matchAll + } + + return sf +} + +type SegmentConfig struct { + Excludes []SegmentMatcherFields + Includes []SegmentMatcherFields +} + +// SegmentMatcherFields is a matcher for a segment include or exclude. +// All of these are Glob patterns. +type SegmentMatcherFields struct { + Kind string + Path string + Lang string + Output string +} + +func getGlob(s string) (glob.Glob, error) { + if s == "" { + return nil, nil + } + g, err := hglob.GetGlob(s) + if err != nil { + return nil, fmt.Errorf("failed to compile Glob %q: %w", s, err) + } + return g, nil +} + +func compileSegments(f []SegmentMatcherFields) (predicate.P[SegmentMatcherFields], error) { + if f == nil { + return func(SegmentMatcherFields) bool { return false }, nil + } + var ( + result predicate.P[SegmentMatcherFields] + section predicate.P[SegmentMatcherFields] + ) + + addToSection := func(matcherFields SegmentMatcherFields, f func(fields SegmentMatcherFields) string) error { + s1 := f(matcherFields) + g, err := getGlob(s1) + if err != nil { + return err + } + matcher := func(fields SegmentMatcherFields) bool { + s2 := f(fields) + if s2 == "" { + return false + } + return g.Match(s2) + } + if section == nil { + section = matcher + } else { + section = section.And(matcher) + } + return nil + } + + for _, fields := range f { + if fields.Kind != "" { + if err := addToSection(fields, func(fields SegmentMatcherFields) string { return fields.Kind }); err != nil { + return result, err + } + } + if fields.Path != "" { + if err := addToSection(fields, func(fields SegmentMatcherFields) string { return fields.Path }); err != nil { + return result, err + } + } + if fields.Lang != "" { + if err := addToSection(fields, func(fields SegmentMatcherFields) string { return fields.Lang }); err != nil { + return result, err + } + } + if fields.Output != "" { + if err := addToSection(fields, func(fields SegmentMatcherFields) string { return fields.Output }); err != nil { + return result, err + } + } + + if result == nil { + result = section + } else { + result = result.Or(section) + } + section = nil + + } + + return result, nil +} + +func DecodeSegments(in map[string]any) (*config.ConfigNamespace[map[string]SegmentConfig, Segments], error) { + buildConfig := func(in any) (Segments, any, error) { + sms := Segments{ + s: map[string]excludeInclude{}, + } + m, err := maps.ToStringMapE(in) + if err != nil { + return sms, nil, err + } + if m == nil { + m = map[string]any{} + } + m = maps.CleanConfigStringMap(m) + + var scfgm map[string]SegmentConfig + if err := mapstructure.Decode(m, &scfgm); err != nil { + return sms, nil, err + } + + for k, v := range scfgm { + var ( + include predicate.P[SegmentMatcherFields] + exclude predicate.P[SegmentMatcherFields] + err error + ) + if v.Excludes != nil { + exclude, err = compileSegments(v.Excludes) + if err != nil { + return sms, nil, err + } + } + if v.Includes != nil { + include, err = compileSegments(v.Includes) + if err != nil { + return sms, nil, err + } + } + + ei := excludeInclude{ + exclude: exclude, + include: include, + } + sms.s[k] = ei + + } + + return sms, nil, nil + } + + ns, err := config.DecodeNamespace[map[string]SegmentConfig](in, buildConfig) + if err != nil { + return nil, fmt.Errorf("failed to decode segments: %w", err) + } + return ns, nil +} diff --git a/hugolib/segments/segments_integration_test.go b/hugolib/segments/segments_integration_test.go new file mode 100644 index 000000000..465a7abe0 --- /dev/null +++ b/hugolib/segments/segments_integration_test.go @@ -0,0 +1,76 @@ +// 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 segments_test + +import ( + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/hugolib" +) + +func TestSegments(t *testing.T) { + files := ` +-- hugo.toml -- +baseURL = "https://example.org/" +renderSegments = ["docs"] +[languages] +[languages.en] +weight = 1 +[languages.no] +weight = 2 +[languages.nb] +weight = 3 +[segments] +[segments.docs] +[[segments.docs.includes]] +kind = "{home,taxonomy,term}" +[[segments.docs.includes]] +path = "{/docs,/docs/**}" +[[segments.docs.excludes]] +path = "/blog/**" +[[segments.docs.excludes]] +lang = "n*" +output = "rss" +[[segments.docs.excludes]] +output = "json" +-- layouts/_default/single.html -- +Single: {{ .Title }}|{{ .RelPermalink }}| +-- layouts/_default/list.html -- +List: {{ .Title }}|{{ .RelPermalink }}| +-- content/docs/_index.md -- +-- content/docs/section1/_index.md -- +-- content/docs/section1/page1.md -- +--- +title: "Docs Page 1" +tags: ["tag1", "tag2"] +--- +-- content/blog/_index.md -- +-- content/blog/section1/page1.md -- +--- +title: "Blog Page 1" +tags: ["tag1", "tag2"] +--- +` + + b := hugolib.Test(t, files) + b.Assert(b.H.Configs.Base.RootConfig.RenderSegments, qt.DeepEquals, []string{"docs"}) + + b.AssertFileContent("public/docs/section1/page1/index.html", "Docs Page 1") + b.AssertFileExists("public/blog/section1/page1/index.html", false) + b.AssertFileExists("public/index.html", true) + b.AssertFileExists("public/index.xml", true) + b.AssertFileExists("public/no/index.html", true) + b.AssertFileExists("public/no/index.xml", false) +} diff --git a/hugolib/segments/segments_test.go b/hugolib/segments/segments_test.go new file mode 100644 index 000000000..1a2dfb97b --- /dev/null +++ b/hugolib/segments/segments_test.go @@ -0,0 +1,115 @@ +package segments + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestCompileSegments(t *testing.T) { + c := qt.New(t) + + c.Run("excludes", func(c *qt.C) { + fields := []SegmentMatcherFields{ + { + Lang: "n*", + Output: "rss", + }, + } + + match, err := compileSegments(fields) + c.Assert(err, qt.IsNil) + + check := func() { + c.Assert(match, qt.IsNotNil) + c.Assert(match(SegmentMatcherFields{Lang: "no"}), qt.Equals, false) + c.Assert(match(SegmentMatcherFields{Lang: "no", Kind: "page"}), qt.Equals, false) + c.Assert(match(SegmentMatcherFields{Lang: "no", Output: "rss"}), qt.Equals, true) + c.Assert(match(SegmentMatcherFields{Lang: "no", Output: "html"}), qt.Equals, false) + c.Assert(match(SegmentMatcherFields{Kind: "page"}), qt.Equals, false) + c.Assert(match(SegmentMatcherFields{Lang: "no", Output: "rss", Kind: "page"}), qt.Equals, true) + } + + check() + + fields = []SegmentMatcherFields{ + { + Path: "/blog/**", + }, + { + Lang: "n*", + Output: "rss", + }, + } + + match, err = compileSegments(fields) + c.Assert(err, qt.IsNil) + check() + c.Assert(match(SegmentMatcherFields{Path: "/blog/foo"}), qt.Equals, true) + }) + + c.Run("includes", func(c *qt.C) { + fields := []SegmentMatcherFields{ + { + Path: "/docs/**", + }, + { + Lang: "no", + Output: "rss", + }, + } + + match, err := compileSegments(fields) + c.Assert(err, qt.IsNil) + c.Assert(match, qt.IsNotNil) + c.Assert(match(SegmentMatcherFields{Lang: "no"}), qt.Equals, false) + c.Assert(match(SegmentMatcherFields{Kind: "page"}), qt.Equals, false) + c.Assert(match(SegmentMatcherFields{Kind: "page", Path: "/blog/foo"}), qt.Equals, false) + c.Assert(match(SegmentMatcherFields{Lang: "en"}), qt.Equals, false) + c.Assert(match(SegmentMatcherFields{Lang: "no", Output: "rss"}), qt.Equals, true) + c.Assert(match(SegmentMatcherFields{Lang: "no", Output: "html"}), qt.Equals, false) + c.Assert(match(SegmentMatcherFields{Kind: "page", Path: "/docs/foo"}), qt.Equals, true) + }) + + c.Run("includes variant1", func(c *qt.C) { + c.Skip() + + fields := []SegmentMatcherFields{ + { + Kind: "home", + }, + { + Path: "{/docs,/docs/**}", + }, + } + + match, err := compileSegments(fields) + c.Assert(err, qt.IsNil) + c.Assert(match, qt.IsNotNil) + c.Assert(match(SegmentMatcherFields{Path: "/blog/foo"}), qt.Equals, false) + c.Assert(match(SegmentMatcherFields{Kind: "page", Path: "/docs/foo"}), qt.Equals, true) + c.Assert(match(SegmentMatcherFields{Kind: "home", Path: "/"}), qt.Equals, true) + }) +} + +func BenchmarkSegmentsMatch(b *testing.B) { + fields := []SegmentMatcherFields{ + { + Path: "/docs/**", + }, + { + Lang: "no", + Output: "rss", + }, + } + + match, err := compileSegments(fields) + if err != nil { + b.Fatal(err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + match(SegmentMatcherFields{Lang: "no", Output: "rss"}) + } +} |