hugo/hugolib/segments/segments.go
Bjørn Erik Pedersen 1f1c62e6c7 Add segments config + --renderSegments flag
Named segments can be defined in `hugo.toml`.

* Eeach segment consists of zero or more `exclude` filters and zero or more `include` filters.
* Eeach filter consists of one or more field Glob matchers.
* Eeach filter in a section (`exclude` or `include`) is ORed together, each matcher in a filter is ANDed together.

The current list of fields that can be filtered are:

* path as defined in https://gohugo.io/methods/page/path/
* kind
* lang
* output (output format, e.g. html).

It is recommended to put coarse grained filters (e.g. for language and output format) in the excludes section, e.g.:

```toml
[segments.segment1]
  [[segments.segment1.excludes]]
    lang = "n*"
  [[segments.segment1.excludes]]
    no     = "en"
    output = "rss"
  [[segments.segment1.includes]]
    term = "{home,term,taxonomy}"
  [[segments.segment1.includes]]
    path = "{/docs,/docs/**}"
```

By default, Hugo will render all segments, but you can enable filters by setting the `renderSegments` option or `--renderSegments` flag, e.g:

```
hugo --renderSegments segment1,segment2
```

For segment `segment1` in the configuration above, this will:

* Skip rendering of all languages matching `n*`, e.g. `no`.
* Skip rendering of the output format `rss` for the `en` language.
* It will render all pages of kind `home`, `term` or `taxonomy`
* It will render the `/docs` section and all pages below.

Fixes #10106
2024-03-16 15:53:26 +01:00

258 lines
6.5 KiB
Go

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