Compare commits

..

21 Commits

Author SHA1 Message Date
hugoreleaser f42c422a12 releaser: Prepare repository for 0.155.0-DEV
[ci skip]
2025-12-31 13:09:12 +00:00
hugoreleaser 0b71db299a releaser: Bump versions for release of 0.154.0
[ci skip]
2025-12-31 12:45:55 +00:00
Bjørn Erik Pedersen 5c7fad23a3 tpl/collections: Fix apply to work with built-in funcs like len
Fixes #13418
2025-12-31 13:09:04 +01:00
Bjørn Erik Pedersen 700bb78198 Remove Linode sponsor from README
Removed Linode sponsor link from the README.
2025-12-30 19:54:11 +01:00
majiayu000 d3b5d47a43 helpers: Limit verbose watch output for better readability
Limits the number of root groups shown in 'Watching for changes' output
to 10 maximum, with a summary message for remaining paths. This prevents
the message from becoming excessively long when watching sites with many
mount points.

Fixes #14277

Signed-off-by: majiayu000 <1835304752@qq.com>
2025-12-30 19:44:35 +01:00
Bjørn Erik Pedersen 86cd1838ff tpl/reflect: Make the IsImageResource implementation less technical
And closer to the documentation. The old one was correct, but a bit too tied to the internal implementation.
2025-12-30 19:43:55 +01:00
Joe Mooring 871da3370a internal/warpc: Increase WebP memory limit to 384 MiB
Closes #14309
2025-12-30 16:51:58 +01:00
dependabot[bot] 2637aa1540 build(deps): bump github.com/tetratelabs/wazero from 1.10.1 to 1.11.0
Bumps [github.com/tetratelabs/wazero](https://github.com/tetratelabs/wazero) from 1.10.1 to 1.11.0.
- [Release notes](https://github.com/tetratelabs/wazero/releases)
- [Commits](https://github.com/tetratelabs/wazero/compare/v1.10.1...v1.11.0)

---
updated-dependencies:
- dependency-name: github.com/tetratelabs/wazero
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-30 16:01:32 +01:00
Bjørn Erik Pedersen bf1d20d735 Revert "resources/page: Fix slugorcontentbasename for section pages"
This reverts commit 25c7c18f9f.

See #14104
See #14325
2025-12-30 16:00:37 +01:00
Bjørn Erik Pedersen 1deec99bba Update tpl/reflect/reflect.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-30 16:00:19 +01:00
Bjørn Erik Pedersen b7bb557c6a Add reflect.Is{Page,Site,Resource,ImageResource}
Fixes #14307
2025-12-30 16:00:19 +01:00
Bjørn Erik Pedersen 7c19c196c3 Allow partials to work as decorators
Fixes #13193
2025-12-30 15:58:55 +01:00
hugoreleaser 555443b64a releaser: Prepare repository for 0.154.0-DEV
[ci skip]
2025-12-30 10:48:50 +00:00
hugoreleaser 1f2de189ed releaser: Bump versions for release of 0.153.5
[ci skip]
2025-12-30 10:27:17 +00:00
Bjørn Erik Pedersen c6ae33c61d images: Add compression option to image config and clean up some of the options handling
The original idea of setting quality to 0 for lossless WebP was didn't allow a setting that made sense for both JPEG and WebP.

Note that the above didn't really work at all, so this should not break anything.
2025-12-29 21:38:19 +01:00
Bjørn Erik Pedersen edeebf0d33 config: Fix cascade per language in hugo.toml regression
An onfurtunate side effect of the fix in v0.153.4.

Fixes #14321
2025-12-29 20:48:12 +01:00
Simon Heimlicher ea9675f6e7 images: Fix WebP quality and hint parameters being ignored
Fixes #14316
2025-12-29 16:47:43 +01:00
hugoreleaser b1f7e35a98 releaser: Prepare repository for 0.154.0-DEV
[ci skip]
2025-12-28 21:55:32 +00:00
hugoreleaser a0d4e1fb26 releaser: Bump versions for release of 0.153.4
[ci skip]
2025-12-28 21:34:16 +00:00
Bjørn Erik Pedersen 96777d9b89 Set cascade target to the content matrix if not set in the cascade itself
Fixes #14310

Co-authored-by: Joe Mooring <joe.mooring@veriphor.com>
2025-12-28 22:30:55 +01:00
hugoreleaser 25126e5f87 releaser: Prepare repository for 0.154.0-DEV
[ci skip]
2025-12-26 16:33:20 +00:00
56 changed files with 1161 additions and 177 deletions
-2
View File
@@ -65,8 +65,6 @@ See the [features] section of the documentation for a comprehensive summary of H
<p>&nbsp;</p>
<p float="left">
<a href="https://www.linode.com/?utm_campaign=hugosponsor&utm_medium=banner&utm_source=hugogithub" target="_blank"><img src="https://raw.githubusercontent.com/gohugoio/hugoDocs/master/assets/images/sponsors/linode-logo_standard_light_medium.png" width="200" alt="Linode"></a>
&nbsp;&nbsp;&nbsp;
<a href="https://www.jetbrains.com/go/?utm_source=OSS&utm_medium=referral&utm_campaign=hugo" target="_blank"><img src="https://raw.githubusercontent.com/gohugoio/hugoDocs/master/assets/images/sponsors/goland.svg" width="200" alt="The complete IDE crafted for professional Go developers."></a>
&nbsp;&nbsp;&nbsp;
<a href="https://cloudcannon.com/hugo-cms/?utm_campaign=HugoSponsorship&utm_source=sponsor&utm_content=gohugo" target="_blank"><img src="https://raw.githubusercontent.com/gohugoio/hugoDocs/master/assets/images/sponsors/cloudcannon-cms-logo.svg" width="200" alt="CloudCannon"></a>
+2 -2
View File
@@ -69,7 +69,7 @@ func New(opts Options) *Cache {
infol := opts.Log.InfoCommand("dynacache")
evictedIdentities := collections.NewStack[KeyIdentity]()
evictedIdentities := collections.NewStackThreadSafe[KeyIdentity]()
onEvict := func(k, v any) {
if !opts.Watching {
@@ -129,7 +129,7 @@ type Cache struct {
partitions map[string]PartitionManager
onEvict func(k, v any)
evictedIdentities *collections.Stack[KeyIdentity]
evictedIdentities *collections.StackThreadSafe[KeyIdentity]
opts Options
infol logg.LevelLogger
+55 -11
View File
@@ -21,24 +21,24 @@ import (
"github.com/gohugoio/hugo/common/hiter"
)
// Stack is a simple LIFO stack that is safe for concurrent use.
type Stack[T any] struct {
// StackThreadSafe is a simple LIFO stack that is safe for concurrent use.
type StackThreadSafe[T any] struct {
items []T
zero T
mu sync.RWMutex
}
func NewStack[T any]() *Stack[T] {
return &Stack[T]{}
func NewStackThreadSafe[T any]() *StackThreadSafe[T] {
return &StackThreadSafe[T]{}
}
func (s *Stack[T]) Push(item T) {
func (s *StackThreadSafe[T]) Push(item T) {
s.mu.Lock()
defer s.mu.Unlock()
s.items = append(s.items, item)
}
func (s *Stack[T]) Pop() (T, bool) {
func (s *StackThreadSafe[T]) Pop() (T, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.items) == 0 {
@@ -49,7 +49,7 @@ func (s *Stack[T]) Pop() (T, bool) {
return item, true
}
func (s *Stack[T]) Peek() (T, bool) {
func (s *StackThreadSafe[T]) Peek() (T, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.items) == 0 {
@@ -58,18 +58,18 @@ func (s *Stack[T]) Peek() (T, bool) {
return s.items[len(s.items)-1], true
}
func (s *Stack[T]) Len() int {
func (s *StackThreadSafe[T]) Len() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.items)
}
// All returns all items in the stack, from bottom to top.
func (s *Stack[T]) All() iter.Seq2[int, T] {
func (s *StackThreadSafe[T]) All() iter.Seq2[int, T] {
return hiter.Lock2(slices.All(s.items), s.mu.RLock, s.mu.RUnlock)
}
func (s *Stack[T]) Drain() []T {
func (s *StackThreadSafe[T]) Drain() []T {
s.mu.Lock()
defer s.mu.Unlock()
items := s.items
@@ -77,7 +77,7 @@ func (s *Stack[T]) Drain() []T {
return items
}
func (s *Stack[T]) DrainMatching(predicate func(T) bool) []T {
func (s *StackThreadSafe[T]) DrainMatching(predicate func(T) bool) []T {
s.mu.Lock()
defer s.mu.Unlock()
var items []T
@@ -89,3 +89,47 @@ func (s *Stack[T]) DrainMatching(predicate func(T) bool) []T {
}
return items
}
// Stack is a simple LIFO stack that is not safe for concurrent use.
type Stack[T any] struct {
items []T
zero T
}
func NewStack[T any]() *Stack[T] {
return &Stack[T]{}
}
func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}
func (s *Stack[T]) Pop() (T, bool) {
if len(s.items) == 0 {
return s.zero, false
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item, true
}
func (s *Stack[T]) Peek() (T, bool) {
if len(s.items) == 0 {
return s.zero, false
}
return s.items[len(s.items)-1], true
}
func (s *Stack[T]) Len() int {
return len(s.items)
}
func (s *Stack[T]) All() iter.Seq2[int, T] {
return slices.All(s.items)
}
func (s *Stack[T]) Drain() []T {
items := s.items
s.items = nil
return items
}
+4 -4
View File
@@ -10,7 +10,7 @@ func TestNewStack(t *testing.T) {
t.Parallel()
c := qt.New(t)
s := NewStack[int]()
s := NewStackThreadSafe[int]()
c.Assert(s, qt.IsNotNil)
}
@@ -19,7 +19,7 @@ func TestStackBasic(t *testing.T) {
t.Parallel()
c := qt.New(t)
s := NewStack[int]()
s := NewStackThreadSafe[int]()
c.Assert(s.Len(), qt.Equals, 0)
@@ -50,7 +50,7 @@ func TestStackDrain(t *testing.T) {
t.Parallel()
c := qt.New(t)
s := NewStack[string]()
s := NewStackThreadSafe[string]()
s.Push("a")
s.Push("b")
@@ -64,7 +64,7 @@ func TestStackDrainMatching(t *testing.T) {
t.Parallel()
c := qt.New(t)
s := NewStack[int]()
s := NewStackThreadSafe[int]()
s.Push(1)
s.Push(2)
s.Push(3)
+3 -3
View File
@@ -19,7 +19,7 @@ import "github.com/gohugoio/hugo/common/version"
// This should be the only one.
var CurrentVersion = version.Version{
Major: 0,
Minor: 153,
PatchLevel: 3,
Suffix: "",
Minor: 155,
PatchLevel: 0,
Suffix: "-DEV",
}
+12
View File
@@ -73,6 +73,18 @@ func (m *Map[K, T]) Set(key K, value T) {
m.mu.Unlock()
}
// Delete deletes the given key from the map.
// It returns true if the key was found and deleted, false otherwise.
func (m *Map[K, T]) Delete(key K) bool {
m.mu.Lock()
defer m.mu.Unlock()
if _, found := m.m[key]; found {
delete(m.m, key)
return true
}
return false
}
// WithWriteLock executes the given function with a write lock on the map.
func (m *Map[K, T]) WithWriteLock(f func(m map[K]T) error) error {
m.mu.Lock()
+1 -2
View File
@@ -915,10 +915,9 @@ func (c *Configs) Init(logger loggers.Logger) error {
// avoid initializing the same config more than once.
for i, l := range c.Languages {
langConfig := c.LanguageConfigMap[l.Lang]
sitesMatrix := sitesmatrix.NewIntSetsBuilder(c.ConfiguredDimensions).WithLanguageIndices(i).WithAllIfNotSet().Build()
for _, s := range allDecoderSetups {
if getInitializer := s.getInitializer; getInitializer != nil {
if err := getInitializer(langConfig).InitConfig(logger, sitesMatrix, c.ConfiguredDimensions); err != nil {
if err := getInitializer(langConfig).InitConfig(logger, nil, c.ConfiguredDimensions); err != nil {
return err
}
}
+1 -1
View File
@@ -69,7 +69,7 @@ require (
github.com/spf13/pflag v1.0.9
github.com/tdewolff/minify/v2 v2.24.8
github.com/tdewolff/parse/v2 v2.8.5
github.com/tetratelabs/wazero v1.10.1
github.com/tetratelabs/wazero v1.11.0
github.com/yuin/goldmark v1.7.13
github.com/yuin/goldmark-emoji v1.0.6
go.uber.org/automaxprocs v1.5.3
+2 -2
View File
@@ -511,8 +511,8 @@ github.com/tdewolff/parse/v2 v2.8.5 h1:ZmBiA/8Do5Rpk7bDye0jbbDUpXXbCdc3iah4VeUvw
github.com/tdewolff/parse/v2 v2.8.5/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8=
github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
+8
View File
@@ -137,6 +137,7 @@ func ExtractAndGroupRootPaths(in []string) []string {
return nil
}
const maxGroups = 5
const maxRootGroups = 10
sort.Strings(in)
var groups []string
tree := radix.New[[]string]()
@@ -186,6 +187,13 @@ LOOP:
tree.Walk(collect)
// Limit the total number of root groups to keep output manageable
if len(groups) > maxRootGroups {
remaining := len(groups) - maxRootGroups
groups = groups[:maxRootGroups]
groups = append(groups, fmt.Sprintf("... and %d more", remaining))
}
return groups
}
+27 -12
View File
@@ -345,19 +345,34 @@ func TestAbsPathify(t *testing.T) {
}
func TestExtractAndGroupRootPaths(t *testing.T) {
in := []string{
filepath.FromSlash("/a/b/c/d"),
filepath.FromSlash("/a/b/c/e"),
filepath.FromSlash("/a/b/e/f"),
filepath.FromSlash("/a/b"),
filepath.FromSlash("/a/b/c/b/g"),
filepath.FromSlash("/c/d/e"),
}
result := helpers.ExtractAndGroupRootPaths(in)
c := qt.New(t)
c.Assert(result, qt.DeepEquals, []string{"/a/b/{c,e}", "/c/d/e"})
t.Run("Basic grouping", func(t *testing.T) {
in := []string{
filepath.FromSlash("/a/b/c/d"),
filepath.FromSlash("/a/b/c/e"),
filepath.FromSlash("/a/b/e/f"),
filepath.FromSlash("/a/b"),
filepath.FromSlash("/a/b/c/b/g"),
filepath.FromSlash("/c/d/e"),
}
result := helpers.ExtractAndGroupRootPaths(in)
c.Assert(result, qt.DeepEquals, []string{"/a/b/{c,e}", "/c/d/e"})
})
t.Run("Limits number of root groups", func(t *testing.T) {
in := []string{}
// Create 15 different root paths to exceed maxRootGroups (10)
for i := 0; i < 15; i++ {
in = append(in, filepath.FromSlash(fmt.Sprintf("/path%d/subdir", i)))
}
result := helpers.ExtractAndGroupRootPaths(in)
// Should have 10 paths + 1 "... and X more" message
c.Assert(len(result), qt.Equals, 11)
c.Assert(result[10], qt.Matches, `\.\.\. and \d+ more`)
})
}
func BenchmarkExtractAndGroupRootPaths(b *testing.B) {
+1 -2
View File
@@ -29,7 +29,6 @@ import (
"github.com/gohugoio/hugo/output"
"github.com/gohugoio/hugo/publisher"
"github.com/gohugoio/hugo/resources/page"
"github.com/gohugoio/hugo/tpl"
"github.com/gohugoio/hugo/tpl/tplimpl"
)
@@ -76,7 +75,7 @@ func (a aliasHandler) renderAlias(permalink string, p page.Page, matrix sitesmat
p,
}
ctx := tpl.Context.Page.Set(context.Background(), p)
ctx := a.ts.PrepareTopLevelRenderCtx(context.Background(), p)
buffer := new(bytes.Buffer)
err := a.ts.ExecuteWithContext(ctx, t, buffer, data)
+51
View File
@@ -365,3 +365,54 @@ Resource: {{ .Name }}|p1: {{ .Params.p1 }}|
b.AssertFileContent("public/b1/index.html", "Title: b1|p1: v1|", "Resource: p2.md|p1: v1|")
}
// Issue 14310
// Issue 14321
func TestCascadeIssue14310(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
disableKinds = ['home', 'rss', 'sitemap', 'taxonomy', 'term']
defaultContentLanguage = 'en'
defaultContentLanguageInSubdir = true
[languages.en]
weight = 1
[languages.de]
weight = 2
[[cascade]]
[cascade.params]
size = 'medium'
[cascade.target]
kind = 'page'
-- layouts/all.html --
|color: {{ .Params.color }}|size: {{ .Params.size }}|
-- content/s1/_index.de.md --
---
title: s1 (de)
cascade:
params:
color: red (de)
---
-- content/s1/_index.en.md --
---
title: s1 (en)
cascade:
params:
color: red (en)
---
-- content/s1/p1.de.md --
---
title: p1 (de)
---
-- content/s1/p1.en.md --
---
title: p1 (en)
---
`
b := Test(t, files)
b.AssertFileContent("public/en/s1/p1/index.html", "|color: red (en)|size: medium|") // fails: file contains "|color: red (en)|size: |"
b.AssertFileContent("public/de/s1/p1/index.html", "|color: red (de)|size: medium|")
}
+4
View File
@@ -117,6 +117,10 @@ func (tree *SimpleTree[T]) All() iter.Seq2[string, T] {
}
}
func (tree *SimpleTree[T]) Len() int {
return tree.tree.Len()
}
// NewSimpleThreadSafeTree creates a new SimpleTree.
func NewSimpleThreadSafeTree[T any]() *SimpleThreadSafeTree[T] {
return &SimpleThreadSafeTree[T]{tree: radix.New[T](), mu: new(sync.RWMutex)}
+3 -3
View File
@@ -224,7 +224,7 @@ type WalkContext[T any] struct {
events []*Event[T]
hooksPostInit sync.Once
hooksPost *collections.Stack[func() error]
hooksPost *collections.StackThreadSafe[func() error]
}
type eventHandlers[T any] map[string][]func(*Event[T])
@@ -261,9 +261,9 @@ func (ctx *WalkContext[T]) HandleEvents() error {
return nil
}
func (ctx *WalkContext[T]) HooksPost() *collections.Stack[func() error] {
func (ctx *WalkContext[T]) HooksPost() *collections.StackThreadSafe[func() error] {
ctx.hooksPostInit.Do(func() {
ctx.hooksPost = collections.NewStack[func() error]()
ctx.hooksPost = collections.NewStackThreadSafe[func() error]()
})
return ctx.hooksPost
}
+1 -1
View File
@@ -544,7 +544,7 @@ Content Tag 1.
b.AssertFileContent("public/en/posts/p1/index.html",
"Single: en|page|/en/posts/p1/|Post 1|<p>Content 1.</p>\n|Len Resources: 2|",
"Resources: text|/en/posts/p1/f1.txt|text/plain|map[icon:enicon] - page||application/octet-stream|map[background:post.jpg draft:false iscjklanguage:false title:Post Sub 1] -",
"Resources: text|/en/posts/p1/f1.txt|text/plain|map[icon:enicon] - page||application/octet-stream|map[draft:false iscjklanguage:false title:Post Sub 1] -",
"Icon: enicon",
"Icon fingerprinted: enicon|/en/posts/p1/f1.e5746577af5cbfc4f34c558051b7955a9a5a795a84f1c6ab0609cb3473a924cb.txt|",
"NextInSection: |\nPrevInSection: /en/posts/p2/|Post 2|",
+2 -1
View File
@@ -19,6 +19,7 @@ import (
"testing"
"github.com/bep/logg"
"github.com/yuin/goldmark/util"
qt "github.com/frankban/quicktest"
"github.com/fsnotify/fsnotify"
@@ -419,7 +420,7 @@ func (s *IntegrationTestBuilder) AssertFileContent(filename string, matches ...s
content := strings.TrimSpace(s.FileContent(filename))
for _, m := range matches {
cm := qt.Commentf("File: %s Expect: %s Got: %s", filename, m, content)
cm := qt.Commentf("File: %s Expect:\n%s Got:\n%s\nWith Space Visuals:\n%s", filename, m, content, util.VisualizeSpaces([]byte(content)))
lines := strings.SplitSeq(m, "\n")
for match := range lines {
match = strings.TrimSpace(match)
+2 -2
View File
@@ -266,7 +266,7 @@ func NewHugoSites(cfg deps.DepsCfg) (*HugoSites, error) {
warpc.Options{
CompilationCacheDir: compilationCacheDir,
PoolSize: 1,
Memory: 256, // 256 MiB (4096 MiB Max)
Memory: 384, // 384 MiB (4096 MiB Max)
Infof: logger.InfoCommand("webp").Logf,
Warnf: logger.WarnCommand("webp").Logf,
},
@@ -1610,7 +1610,7 @@ func (s *Site) renderAndWritePage(statCounter *uint64, targetPath string, p *pag
of := p.outputFormat()
p.incrRenderState()
ctx := tpl.Context.Page.Set(context.Background(), p)
ctx := s.TemplateStore.PrepareTopLevelRenderCtx(context.Background(), p)
ctx = tpl.Context.DependencyManagerScopedProvider.Set(ctx, p)
if err := s.renderForTemplate(ctx, p.Kind(), of.Name, d, renderBuffer, templ); err != nil {
+6 -2
View File
@@ -1,7 +1,11 @@
# Release env.
# These will be replaced by script before release.
HUGORELEASER_TAG=v0.153.2
HUGORELEASER_COMMITISH=798533a2013eab97198b0a155a8f4afab7e79865
HUGORELEASER_TAG=v0.154.0
HUGORELEASER_COMMITISH=0b71db299a2bd89be876d7dc972ded03a222f560
+11 -4
View File
@@ -73,9 +73,10 @@ typedef struct
typedef struct
{
float quality; // between 0 and 100. Set to 0 for lossless.
char hint[64]; // drawing, icon, photo, picture, or text. Default is photo.
int preset; // preset to use; resolved from hint.
float quality; // between 1 and 100.
char compression[32]; // "lossy" or "lossless"
char hint[64]; // drawing, icon, photo, picture, or text. Default is photo.
int preset; // preset to use; resolved from hint.
bool useSharpYuv; // use sharp YUV for better quality.
@@ -302,7 +303,7 @@ static uint8_t initEncoderConfig(WebPConfig *config, InputOptions opts)
return 0;
}
if (opts.quality == 0)
if (strcmp(opts.compression, "lossless") == 0)
{
// Activate the lossless compression mode with the desired efficiency level
// between 0 (fastest, lowest compression) and 9 (slower, best compression).
@@ -396,6 +397,12 @@ InputMessage parse_input_message(const char *line)
if (options_object != NULL)
{
msg.data.options.quality = (int)json_object_get_number(options_object, "quality");
const char *compression_str = json_object_get_string(options_object, "compression");
if (compression_str != NULL)
{
strncpy(msg.data.options.compression, compression_str, sizeof(msg.data.options.compression) - 1);
msg.data.options.compression[sizeof(msg.data.options.compression) - 1] = '\0';
}
const char *hint_str = json_object_get_string(options_object, "hint");
if (hint_str != NULL)
{
+2 -4
View File
@@ -792,11 +792,9 @@ func (d *Dispatchers) Webp() (Dispatcher[WebpInput, WebpOutput], error) {
return d.webp.start()
}
func (d *Dispatchers) NewWepCodec(quality int, hint string) (*WebpCodec, error) {
func (d *Dispatchers) NewWepCodec() (*WebpCodec, error) {
return &WebpCodec{
d: d.Webp,
quality: quality,
hint: hint,
d: d.Webp,
}, nil
}
Binary file not shown.
+7 -14
View File
@@ -22,6 +22,7 @@ import (
"image/color"
"image/draw"
"io"
"maps"
"github.com/gohugoio/hugo/common/himage"
"github.com/gohugoio/hugo/common/hugio"
@@ -82,9 +83,7 @@ type WebpOutput struct {
}
type WebpCodec struct {
d func() (Dispatcher[WebpInput, WebpOutput], error)
quality int
hint string
d func() (Dispatcher[WebpInput, WebpOutput], error)
}
// Decode reads a WEBP image from r and returns it as an image.Image.
@@ -201,7 +200,7 @@ func (d *WebpCodec) DecodeConfig(r io.Reader) (image.Config, error) {
}, nil
}
func (d *WebpCodec) Encode(w io.Writer, img image.Image) error {
func (d *WebpCodec) Encode(w io.Writer, img image.Image, opts map[string]any) error {
b := img.Bounds()
if b.Dx() >= 1<<16 || b.Dy() >= 1<<16 {
return errors.New("webp: image is too large to encode")
@@ -294,11 +293,9 @@ func (d *WebpCodec) Encode(w io.Writer, img image.Image) error {
return fmt.Errorf("no image bytes extracted from %T", img)
}
// Commands:
// encodeNRGBA
// encodeGray
// decode
// config
opts = maps.Clone(opts)
opts["useSharpYuv"] = true // Use sharp (and slow) RGB->YUV conversion.
message := Message[WebpInput]{
Header: Header{
Version: 1,
@@ -310,11 +307,7 @@ func (d *WebpCodec) Encode(w io.Writer, img image.Image) error {
Data: WebpInput{
Source: bytes.NewReader(imageBytes),
Destination: w,
Options: map[string]any{
"quality": d.quality, // a number between 0 and 100. Set to 0 for lossless.
"hint": d.hint, // drawing, icon, photo, picture, or text
"useSharpYuv": true, // Use sharp (and slow) RGB->YUV conversion.
},
Options: opts,
Params: map[string]any{
"width": bounds.Max.X,
"height": bounds.Max.Y,
+15 -10
View File
@@ -37,27 +37,27 @@ type Decoder interface {
DecodeConfig(r io.Reader) (image.Config, error)
}
// Encoder defines the encoding of an image format.
type Encoder interface {
Encode(w io.Writer, src image.Image) error
}
type ToEncoder interface {
EncodeTo(conf ImageConfig, w io.Writer, src image.Image) error
}
// CodecStdlib defines both decoding and encoding of an image format as defined by the standard library.
type CodecStdlib interface {
// EncoderWithOptions defines the encoding of an image format with the given options.
type Encoder interface {
Encode(w io.Writer, src image.Image, options map[string]any) error
}
// EncodeDecoder defines both decoding and encoding of an image format as defined by the standard library.
type EncodeDecoder interface {
Decoder
Encoder
}
// Codec is a generic image codec supporting multiple formats.
type Codec struct {
webp CodecStdlib
webp EncodeDecoder
}
func newCodec(webp CodecStdlib) *Codec {
func newCodec(webp EncodeDecoder) *Codec {
return &Codec{webp: webp}
}
@@ -123,7 +123,12 @@ func (d *Codec) EncodeTo(conf ImageConfig, w io.Writer, img image.Image) error {
case BMP:
return bmp.Encode(w, img)
case WEBP:
return d.webp.Encode(w, img)
opts := map[string]any{
"compression": conf.Compression,
"quality": conf.Quality,
"hint": conf.Hint,
}
return d.webp.Encode(w, img, opts)
default:
return errors.New("format not supported")
}
+30 -8
View File
@@ -58,7 +58,8 @@ var (
".webp": WEBP,
}
imageFormatsBySubType = map[string]Format{
// These are the image types we can process.
processableImageSubTypes = map[string]Format{
media.Builtin.JPEGType.SubType: JPEG,
media.Builtin.PNGType.SubType: PNG,
media.Builtin.TIFFType.SubType: TIFF,
@@ -85,6 +86,11 @@ var anchorPositions = map[string]gift.Anchor{
smartCropIdentifier: SmartCropAnchor,
}
var compressionMethods = map[string]bool{
"lossy": true,
"lossless": true,
}
// These encoding hints are currently only relevant for Webp.
var hints = map[string]bool{
"picture": true,
@@ -118,7 +124,7 @@ func ImageFormatFromExt(ext string) (Format, bool) {
}
func ImageFormatFromMediaSubType(sub string) (Format, bool) {
f, found := imageFormatsBySubType[sub]
f, found := processableImageSubTypes[sub]
return f, found
}
@@ -127,6 +133,7 @@ const (
defaultResampleFilter = "box"
defaultBgColor = "#ffffff"
defaultHint = "photo"
defaultCompression = "lossy"
)
var (
@@ -135,6 +142,7 @@ var (
"bgColor": defaultBgColor,
"hint": defaultHint,
"quality": defaultJPEGQuality,
"compression": defaultCompression,
}
defaultImageConfig *config.ConfigNamespace[ImagingConfig, ImagingConfigInternal]
@@ -226,6 +234,8 @@ func DecodeImageConfig(options []string, defaults *config.ConfigNamespace[Imagin
c.Filter = filter
} else if _, ok := hints[part]; ok {
c.Hint = part
} else if _, ok := compressionMethods[part]; ok {
c.Compression = part
} else if part[0] == '#' {
c.BgColor, err = hexStringToColorGo(part[1:])
if err != nil {
@@ -239,7 +249,6 @@ func DecodeImageConfig(options []string, defaults *config.ConfigNamespace[Imagin
if c.Quality < 1 || c.Quality > 100 {
return c, errors.New("quality ranges from 1 to 100 inclusive")
}
c.qualitySetForImage = true
} else if part[0] == 'r' {
c.Rotate, err = strconv.Atoi(part[1:])
if err != nil {
@@ -306,10 +315,15 @@ func DecodeImageConfig(options []string, defaults *config.ConfigNamespace[Imagin
}
if c.Quality <= 0 && c.TargetFormat.RequiresDefaultQuality() {
// We need a quality setting for all JPEGs and WEBPs.
// We need a quality setting for all JPEGs and WEBPs,
// unless the user explicitly set quality.
c.Quality = defaults.Config.Imaging.Quality
}
if c.Compression == "" {
c.Compression = defaults.Config.Imaging.Compression
}
if c.BgColor == nil && c.TargetFormat != sourceFormat {
if sourceFormat.SupportsTransparency() && !c.TargetFormat.SupportsTransparency() {
c.BgColor = defaults.Config.BgColor
@@ -339,11 +353,11 @@ type ImageConfig struct {
// If set, this will be used as the key in filenames etc.
Key string
// Quality ranges from 1 to 100 inclusive, higher is better.
// Quality ranges from 0 to 100 inclusive, higher is better.
// This is only relevant for JPEG and WEBP images.
// For WebP, 0 means lossless.
// Default is 75.
Quality int
qualitySetForImage bool // Whether the above is set for this image.
Quality int
// Rotate rotates an image by the given angle counter-clockwise.
// The rotation will be performed first.
@@ -360,6 +374,8 @@ type ImageConfig struct {
// when target is set to webp.
Hint string
Compression string
Width int
Height int
@@ -412,6 +428,11 @@ type ImagingConfig struct {
// Default image quality setting (1-100). Only used for JPEG and WebP images.
Quality int
// Compression method to use.
// One of "lossy" or "lossless".
// Note that lossless is currently only supported for WebP.
Compression string
// Resample filter to use in resize operations.
ResampleFilter string
@@ -431,7 +452,7 @@ type ImagingConfig struct {
}
func (cfg *ImagingConfig) init() error {
if cfg.Quality < 0 || cfg.Quality > 100 {
if cfg.Quality < 1 || cfg.Quality > 100 {
return errors.New("image quality must be a number between 1 and 100")
}
@@ -439,6 +460,7 @@ func (cfg *ImagingConfig) init() error {
cfg.Anchor = strings.ToLower(cfg.Anchor)
cfg.ResampleFilter = strings.ToLower(cfg.ResampleFilter)
cfg.Hint = strings.ToLower(cfg.Hint)
cfg.Compression = strings.ToLower(cfg.Compression)
if cfg.Anchor == "" {
cfg.Anchor = smartCropIdentifier
-1
View File
@@ -133,7 +133,6 @@ func newImageConfig(action string, width, height, quality, rotate int, filter, a
c.Width = width
c.Height = height
c.Quality = quality
c.qualitySetForImage = quality != 75
c.Rotate = rotate
c.BgColor, _ = hexStringToColorGo(bgColor)
c.Anchor = SmartCropAnchor
+5 -4
View File
@@ -137,7 +137,7 @@ func NewImageProcessor(warnl logg.LevelLogger, wasmDispatchers *warpc.Dispatcher
return nil, err
}
webpCodec, err := wasmDispatchers.NewWepCodec(cfg.Config.Imaging.Quality, cfg.Config.Imaging.Hint)
webpCodec, err := wasmDispatchers.NewWepCodec()
if err != nil {
return nil, err
}
@@ -300,9 +300,10 @@ func GetDefaultImageConfig(defaults *config.ConfigNamespace[ImagingConfig, Imagi
defaults = defaultImageConfig
}
return ImageConfig{
Anchor: -1, // The real values start at 0.
Hint: "photo",
Quality: defaults.Config.Imaging.Quality,
Anchor: -1, // The real values start at 0.
Hint: "photo",
Quality: defaults.Config.Imaging.Quality,
Compression: defaults.Config.Imaging.Compression,
}
}
@@ -21,6 +21,17 @@ import (
"github.com/gohugoio/hugo/resources/images/imagetesting"
)
const goldenProcess = `
{{ define "process"}}
{{ $img := .img.Process .spec }}
{{ $ext := path.Ext $img.RelPermalink }}
{{ $name := printf "images/%s%s" (.spec | anchorize) $ext }}
{{ with $img | resources.Copy $name }}
{{ .Publish }}
{{ end }}
{{ end }}
`
// Note, if you're enabling writeGoldenFiles on a MacOS ARM 64 you need to run the test with GOARCH=amd64, e.g.
func TestImagesGoldenFiltersMisc(t *testing.T) {
t.Parallel()
@@ -324,15 +335,8 @@ Home.
{{ template "process" (dict "spec" "resize 100x100 r180" "img" $gopher) }}
{{ template "process" (dict "spec" "resize 300x300 jpg #b31280" "img" $gopher) }}
{{ define "process"}}
{{ $img := .img.Process .spec }}
{{ $ext := path.Ext $img.RelPermalink }}
{{ $name := printf "images/%s%s" (.spec | anchorize) $ext }}
{{ with $img | resources.Copy $name }}
{{ .Publish }}
{{ end }}
{{ end }}
`
` + goldenProcess
opts := imagetesting.DefaultGoldenOpts
opts.T = t
@@ -378,7 +382,7 @@ Home.
{{/* These are sorted. The end file name will be created from the spec + extension, so make sure these are unique. */}}
{{ template "process" (dict "spec" "crop 300x300 gif" "img" $animWebp) }}
{{ template "process" (dict "spec" "crop 300x300 smart" "img" $fuzzyCircle) }}
{{ template "process" (dict "spec" "crop 300x300 smart #ff9999" "img" $fuzzyCircle) }}
{{ template "process" (dict "spec" "crop 300x300 smart #ff9999" "img" $fuzzyCircle) }}
{{ template "process" (dict "spec" "crop 300x300" "img" $animWebp) }}
{{ template "process" (dict "spec" "crop 500x200 smart webp" "img" $sunset) }}
{{ template "process" (dict "spec" "crop 500x200 smart webp" "img" $sunset) }}
@@ -388,6 +392,14 @@ Home.
{{ template "process" (dict "spec" "png" "img" $highContrast) }}
{{ template "process" (dict "spec" "resize 300x300" "img" $giphy) }}
{{ template "process" (dict "spec" "resize 300x300 webp" "img" $giphy) }}
{{ template "process" (dict "spec" "resize 300x300 webp lossless" "img" $sunset) }}
{{ template "process" (dict "spec" "resize 300x300 webp q1" "img" $sunset) }}
{{ template "process" (dict "spec" "resize 300x300 webp q33" "img" $sunset) }}
{{ template "process" (dict "spec" "resize 300x300 webp q75" "img" $sunset) }}
{{ template "process" (dict "spec" "resize 300x300 webp q100" "img" $sunset) }}
{{ template "process" (dict "spec" "resize 300x300 webp drawing" "img" $sunset) }}
{{ template "process" (dict "spec" "resize 300x300 webp icon" "img" $sunset) }}
{{ template "process" (dict "spec" "resize 300x300 webp q50 drawing" "img" $sunset) }}
{{ template "process" (dict "spec" "resize 400x" "img" $highContrast) }}
{{ define "process"}}
@@ -478,7 +490,7 @@ Home.
{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "fit" "spec" "200x200" ) }}
{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop" "spec" "200x200" ) }}
{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop" "spec" "350x400 center" ) }}
{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop" "spec" "350x400 smart" ) }}
{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop" "spec" "350x400 smart" ) }}
{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop" "spec" "350x400 center r90" ) }}
{{ template "invoke" (dict "copyFormat" "jpg" "base" $sunset "method" "crop" "spec" "350x400 center q20" ) }}
{{ template "invoke" (dict "copyFormat" "png" "base" $gopher "method" "resize" "spec" "100x" ) }}
@@ -513,3 +525,37 @@ Home.
imagetesting.RunGolden(opts)
}
func TestImagesGoldenConfigLossyVsQuality(t *testing.T) {
t.Parallel()
if imagetesting.SkipGoldenTests {
t.Skip("Skip golden test on this architecture")
}
files := `
-- hugo.toml --
[imaging]
quality = 90 # will only apply to jpeg in this setup.
compression = "lossless" # for webp
-- assets/sunset.jpg --
sourcefilename: ../testdata/sunset.jpg
-- layouts/home.html --
Home.
{{ $sunset := resources.Get "sunset.jpg" }}
{{ template "process" (dict "spec" "resize 300x300 webp" "img" $sunset) }}
{{ template "process" (dict "spec" "resize 300x300 webp lossy" "img" $sunset) }}
{{ template "process" (dict "spec" "resize 300x300 jpeg" "img" $sunset) }}
` + goldenProcess
// Will be used as the base folder for generated images.
name := "losslessvsquality"
opts := imagetesting.DefaultGoldenOpts
opts.T = t
opts.Name = name
opts.Files = files
imagetesting.RunGolden(opts)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+28 -6
View File
@@ -246,8 +246,8 @@ func (d cascadeConfigDecoder) decodePageMatcher(m any, v *PageMatcher) error {
}
// DecodeCascadeConfigOptions
func (v *PageMatcher) compileSitesMatrix(configuredDimensions *sitesmatrix.ConfiguredDimensions) error {
if v.Sites.Matrix.IsZero() {
func (v *PageMatcher) compileSitesMatrix(defaults sitesmatrix.VectorStore, configuredDimensions *sitesmatrix.ConfiguredDimensions) error {
if v.Sites.Matrix.IsZero() && defaults == nil {
// Nothing to do.
v.SitesMatrixCompiled = nil
return nil
@@ -255,8 +255,12 @@ func (v *PageMatcher) compileSitesMatrix(configuredDimensions *sitesmatrix.Confi
intSetsCfg := sitesmatrix.IntSetsConfig{
Globs: v.Sites.Matrix,
}
b := sitesmatrix.NewIntSetsBuilder(configuredDimensions).WithConfig(intSetsCfg).WithAllIfNotSet()
b := sitesmatrix.NewIntSetsBuilder(configuredDimensions).WithConfig(intSetsCfg)
if defaults != nil && v.Sites.Matrix.IsZero() {
b = b.WithDimensionsFromOtherIfNotSet(defaults)
}
b = b.WithAllIfNotSet()
v.SitesMatrixCompiled = b.Build()
return nil
}
@@ -281,6 +285,17 @@ func (p *PageMatcherParamsConfig) init() error {
return nil
}
func (p *PageMatcherParamsConfig) hasSitesMatrix() bool {
if m, ok := p.Fields["sites"]; ok {
mm := maps.ToStringMap(m)
if sm, found := mm["matrix"]; found {
mmm := maps.ToStringMap(sm)
return len(mmm) > 0
}
}
return false
}
type PageMatcherParamsConfigs struct {
c []*config.ConfigNamespace[[]PageMatcherParamsConfig, CascadeConfig]
}
@@ -347,16 +362,23 @@ func (c *PageMatcherParamsConfigs) SourceHash() uint64 {
return h.Sum64()
}
func (c *PageMatcherParamsConfigs) InitConfig(logger loggers.Logger, _ sitesmatrix.VectorStore, configuredDimensions *sitesmatrix.ConfiguredDimensions) error {
func (c *PageMatcherParamsConfigs) InitConfig(logger loggers.Logger, defaultsIn sitesmatrix.VectorStore, configuredDimensions *sitesmatrix.ConfiguredDimensions) error {
if c == nil {
return nil
}
for _, cc := range c.c {
for i := range cc.Config.Cascades {
checkCascadePattern(logger, cc.Config.Cascades[i].Target)
if err := cc.Config.Cascades[i].Target.compileSitesMatrix(configuredDimensions); err != nil {
ccc := cc.Config.Cascades[i]
checkCascadePattern(logger, ccc.Target)
defaults := defaultsIn
hasSitesMatrix := ccc.hasSitesMatrix()
if hasSitesMatrix {
defaults = nil
}
if err := ccc.Target.compileSitesMatrix(defaults, configuredDimensions); err != nil {
return fmt.Errorf("failed to compile cascade target %d: %w", i, err)
}
cc.Config.Cascades[i] = ccc
}
}
return nil
-5
View File
@@ -336,11 +336,6 @@ func (l PermalinkExpander) pageToPermalinkSectionSlugs(p Page, attr string) (str
// pageToPermalinkContentBaseName returns the URL-safe form of the content base name.
func (l PermalinkExpander) pageToPermalinkContentBaseName(p Page, _ string) (string, error) {
// For section pages with _index.md files, return empty string to match the behavior of pageToPermalinkFilename.
// Sections without files should use their directory name.
if p.PathInfo().IsBranchBundle() && p.File() != nil {
return "", nil
}
return l.urlize(p.PathInfo().Unnormalized().BaseNameNoIdentifier()), nil
}
@@ -457,39 +457,3 @@ title: aBc
b = hugolib.Test(t, files)
b.AssertFileExists("public/aBc/index.html", true)
}
// Issue 14104
func TestIssue14104(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
[permalinks.page]
foo = "/:sections[1:]/:slugorcontentbasename/"
[permalinks.section]
foo = "/:sections[1:]/:slugorcontentbasename/"
-- content/foo/_index.md --
---
title: Foo
---
-- content/foo/bar/_index.md --
---
title: Bar
---
-- content/foo/bar/somepage.md --
---
title: Some Page
---
-- layouts/list.html --
List|{{ .Kind }}|{{ .RelPermalink }}|
-- layouts/single.html --
Single|{{ .Kind }}|{{ .RelPermalink }}|
`
b := hugolib.Test(t, files)
// Section page should be at /bar/index.html, not /bar/bar/index.html
b.AssertFileContent("public/bar/index.html", "List|section|/bar/|")
// Regular page should be at /bar/somepage/index.html
b.AssertFileContent("public/bar/somepage/index.html", "Single|page|/bar/somepage/|")
}
+14
View File
@@ -400,6 +400,20 @@ func (r *resourceAdapter) getImageOps() images.ImageResourceOps {
return img
}
// IsImage reports whether the given resource is an image that can be processed.
func IsImage(v any) bool {
r, ok := v.(resource.Resource)
if !ok {
return false
}
mt := r.MediaType()
if mt.MainType != "image" {
return false
}
_, isImage := images.ImageFormatFromMediaSubType(mt.SubType)
return isImage
}
func (r *resourceAdapter) publish() {
if r.publishOnce == nil {
return
+7 -1
View File
@@ -64,6 +64,8 @@ func (ns *Namespace) Apply(ctx context.Context, c any, fname string, args ...any
}
}
var typeOfReflectValue = reflect.TypeOf(reflect.Value{})
func applyFnToThis(ctx context.Context, fn, this reflect.Value, args ...any) (reflect.Value, error) {
num := fn.Type().NumIn()
if num > 0 && hreflect.IsContextType(fn.Type().In(0)) {
@@ -91,7 +93,11 @@ func applyFnToThis(ctx context.Context, fn, this reflect.Value, args ...any) (re
}*/
for i := range num {
// AssignableTo reports whether xt is assignable to type targ.
// Go's built-in template funcs (e.g. len) use reflect.Value as argument type.
if fn.Type().In(i) == typeOfReflectValue && n[i].Type() != typeOfReflectValue {
n[i] = reflect.ValueOf(n[i])
}
if xt, targ := n[i].Type(), fn.Type().In(i); !xt.AssignableTo(targ) {
return reflect.ValueOf(nil), errors.New("called apply using " + xt.String() + " as type " + targ.String())
}
@@ -39,6 +39,21 @@ baseURL = 'http://example.com/'
`)
}
func TestApplyBuiltInIssue13418(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
baseURL = 'http://example.com/'
-- layouts/home.html --
len: {{ apply (slice "hello") "len" "." }}
not: {{ apply (slice "hello") "not" "." }}
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/index.html", "len: [5]", "not: [false]")
}
// Issue 9865
func TestSortStable(t *testing.T) {
t.Parallel()
+9
View File
@@ -145,6 +145,15 @@ func (ns *Namespace) lookup(name string) (*tplimpl.TemplInfo, error) {
// include is a helper function that lookups and executes the named partial.
// Returns the final template name and the rendered output.
func (ns *Namespace) doInclude(ctx context.Context, key string, templ *tplimpl.TemplInfo, dataList ...any) includeResult {
if templ.ParseInfo.HasPartialInner {
stack := tpl.Context.PartialDecoratorIDStack.Get(ctx)
if stack != nil {
if id, ok := stack.Peek(); ok {
// Signal that inner exists.
id.Bool = true
}
}
}
var data any
if len(dataList) > 0 {
data = dataList[0]
+27
View File
@@ -15,6 +15,9 @@ package reflect
import (
"github.com/gohugoio/hugo/common/hreflect"
"github.com/gohugoio/hugo/resources"
"github.com/gohugoio/hugo/resources/page"
"github.com/gohugoio/hugo/resources/resource"
)
// New returns a new instance of the reflect-namespaced template functions.
@@ -34,3 +37,27 @@ func (ns *Namespace) IsMap(v any) bool {
func (ns *Namespace) IsSlice(v any) bool {
return hreflect.IsSlice(v)
}
// IsPage reports whether v is a Hugo Page.
func (ns *Namespace) IsPage(v any) bool {
_, ok := v.(page.Page)
return ok
}
// IsResource reports whether v is a Hugo Resource.
func (ns *Namespace) IsResource(v any) bool {
_, ok := v.(resource.Resource)
return ok
}
// IsSite reports whether v is a Hugo Site.
func (ns *Namespace) IsSite(v any) bool {
_, ok := v.(page.Site)
return ok
}
// IsImageResource reports whether v is a Hugo Image Resource.
// If this returns true, you may process it and get information about its width, height, etc.
func (ns *Namespace) IsImageResource(v any) bool {
return resources.IsImage(v)
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright 2025 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 reflect_test
import (
"testing"
"github.com/gohugoio/hugo/hugolib"
)
func TestIs(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
-- assets/a.png --
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
-- assets/b.svg --
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<circle cx="50" cy="50" r="40" stroke="black" stroke-width="3" fill="red" />
</svg>
-- assets/c.txt --
This is a text file.
-- assets/d.avif --
AAAAHGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZgAAAOptZXRhAAAAAAAAACFoZGxyAAAAAAAAAABwaWN0AAAAAAAAAAAAAAAAAAAAAA5waXRtAAAAAAABAAAAImlsb2MAAAAAREAAAQABAAAAAAEOAAEAAAAAAAAAEgAAACNpaW5mAAAAAAABAAAAFWluZmUCAAAAAAEAAGF2MDEAAAAAamlwcnAAAABLaXBjbwAAABNjb2xybmNseAABAA0ABoAAAAAMYXYxQ4EgAgAAAAAUaXNwZQAAAAAAAAABAAAAAQAAABBwaXhpAAAAAAMICAgAAAAXaXBtYQAAAAAAAAABAAEEAYIDBAAAABptZGF0EgAKBzgABhAQ0GkyBRAAAAtA
-- layouts/home.html --
{{ $a := resources.Get "a.png" }}
{{ $a10 := $a.Fit "10x10" }}
{{ $b := resources.Get "b.svg" }}
{{ $c := resources.Get "c.txt" }}
{{ $d := resources.Get "d.avif" }}
PNG.ResourceType: {{ $a.ResourceType }}
SVG.ResourceType: {{ $b.ResourceType }}
Text.ResourceType: {{ $c.ResourceType }}
AVIF.ResourceType: {{ $d.ResourceType }}
IsSite: false: {{ reflect.IsSite . }}|true: {{ reflect.IsSite .Site }}|true: {{ reflect.IsSite site }}
IsPage: true: {{ reflect.IsPage . }}|false: {{ reflect.IsPage .Site }}|false: {{ reflect.IsPage site }}
IsResource: true: {{ reflect.IsResource . }}|true: {{ reflect.IsResource $a }}|true: {{ reflect.IsResource $b }}|true: {{ reflect.IsResource $c }}
IsImageResource: false: {{ reflect.IsImageResource . }}|true: {{ reflect.IsImageResource $a }}|true: {{ reflect.IsImageResource $a10 }}|false: {{ reflect.IsImageResource $b }}|false: {{ reflect.IsImageResource $c }}|false: {{ reflect.IsImageResource $d }}
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/index.html", `
PNG.ResourceType: image
SVG.ResourceType: image
Text.ResourceType: text
AVIF.ResourceType: image
IsSite: false: false|true: true|true: true
IsPage: true: true|false: false|false: false
IsResource: true: true|true: true|true: true|true: true
IsImageResource: false: false|true: true|true: true|false: false|false: false|false: false
`)
}
+10
View File
@@ -23,6 +23,7 @@ import (
"github.com/bep/helpers/contexthelpers"
bp "github.com/gohugoio/hugo/bufferpool"
"github.com/gohugoio/hugo/common/collections"
"github.com/gohugoio/hugo/identity"
"github.com/gohugoio/hugo/langs"
@@ -53,6 +54,7 @@ const (
contextKeyPage
contextKeyIsInGoldmark
cntextKeyCurrentTemplateInfo
contextKeyPartialDecoratorIDStack
)
// Context manages values passed in the context to templates.
@@ -63,12 +65,14 @@ var Context = struct {
Page contexthelpers.ContextDispatcher[page]
IsInGoldmark contexthelpers.ContextDispatcher[bool]
CurrentTemplate contexthelpers.ContextDispatcher[*CurrentTemplateInfo]
PartialDecoratorIDStack contexthelpers.ContextDispatcher[*collections.Stack[*StringBool]]
}{
DependencyManagerScopedProvider: contexthelpers.NewContextDispatcher[identity.DependencyManagerScopedProvider](contextKeyDependencyManagerScopedProvider),
DependencyScope: contexthelpers.NewContextDispatcher[int](contextKeyDependencyScope),
Page: contexthelpers.NewContextDispatcher[page](contextKeyPage),
IsInGoldmark: contexthelpers.NewContextDispatcher[bool](contextKeyIsInGoldmark),
CurrentTemplate: contexthelpers.NewContextDispatcher[*CurrentTemplateInfo](cntextKeyCurrentTemplateInfo),
PartialDecoratorIDStack: contexthelpers.NewContextDispatcher[*collections.Stack[*StringBool]](contextKeyPartialDecoratorIDStack),
}
func init() {
@@ -81,6 +85,12 @@ func init() {
}
}
// StringBool is a helper struct to hold a string and a bool value.
type StringBool struct {
Str string
Bool bool
}
type page interface {
IsNode() bool
}
+369
View File
@@ -0,0 +1,369 @@
// Copyright 2025 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 templates_test
import (
"fmt"
"strings"
"testing"
qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/hugolib"
)
func TestDecoratorInnerNeverCalled(t *testing.T) {
t.Parallel()
filesTemplate := `
-- hugo.toml --
disableKinds = ["section", "taxonomy", "term", "sitemap", "RSS"]
-- content/p1.md --
---
title: "Page 1"
---
-- content/p2.md --
---
title: "Page 2"
---
-- layouts/_partials/cards.html --
Start:{{ range . }}{{ PLACEHOLDER . }}{{ end }}End$
-- layouts/home.html --
1:${{ with partial "cards.html" (site.RegularPages) }}{{ printf "Got %T" . }}|{{ end }}$
2:${{ with partial "cards.html" (site.RegularPages | first 0) }}{{ printf "Got %T" . }}|{{ end }}$
`
for _, placeholder := range []string{"inner", "templates.Inner"} {
files := strings.ReplaceAll(filesTemplate, "PLACEHOLDER", placeholder)
b := hugolib.Test(t, files)
b.AssertFileContent("public/index.html",
"1:$Start:Got *hugolib.pageState|Got *hugolib.pageState|End$$",
"2:$Start:End$$",
)
}
}
func TestDecoratorInlinePartialInnerNeverCalled(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
disableKinds = ["section", "taxonomy", "term", "sitemap", "RSS"]
-- content/p1.md --
---
title: "Page 1"
---
-- content/p2.md --
---
title: "Page 2"
---
-- layouts/home.html --
${{ with partial "cards.html" (site.RegularPages) }}{{ printf "Got %T" . }}|{{ end }}$
{{ define "_partials/cards.html" }}Start:{{ range . }}{{ inner . }}{{ end }}End${{ end }}
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/index.html",
"$Start:Got *hugolib.pageState|Got *hugolib.pageState|End$$",
)
}
func TestDecoratorInlinePartial(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
disableKinds = ["section", "taxonomy", "term", "sitemap", "rss"]
-- layouts/home.html --
Home.
{{ with partial "decorate.html" "Important!" }}Notice: {{ . }}{{ end }}
{{ define "_partials/decorate.html" }}<b>{{ inner . }}</b>{{ end }}
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/index.html", "<b>Notice: Important!</b>")
}
func TestDecoratorNestedSimple(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
disableKinds = ["section", "taxonomy", "term", "sitemap", "rss"]
-- layouts/home.html --
Home.
{{ with partial "a.html" "warning" }}{{ with partial "b.html" . }}{{ with partial "c.html" . }}{{ . }}{{ end }}{{ end }}{{ end }}
-- layouts/_partials/a.html --
<a>{{ inner . }}</a>
-- layouts/_partials/b.html --
<b>{{ inner . }}</b>
-- layouts/_partials/c.html --
<c>{{ inner . }}</c>
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/index.html", "<a><b><c>warning</c></b></a>")
}
func TestDecoratorNested2(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
disableKinds = ["section", "taxonomy", "term", "sitemap", "RSS"]
title = "Test title"
-- content/p1.md --
---
title: "Page 1"
---
-- content/p2.md --
---
title: "Page 2"
---
-- layouts/page.html --
{{ .Title }}
-- layouts/home.html --
{{ $pages := site.RegularPages }}
{{ with partial "ul.html" $pages }}<a href="{{ .RelPermalink }}">{{ with partial "bold.html" . }}<span>{{ .LinkTitle }}</span>{{ end }}</a>{{ end }}
-- layouts/_partials/ul.html --
<ul>
{{- range . }}
<li>{{ inner . }}</li>
{{- end }}
</ul>
-- layouts/_partials/bold.html --
<b>{{ inner $ }}</b>
`
b, err := hugolib.TestE(t, files)
b.Assert(err, qt.IsNil)
b.AssertFileContent("public/index.html", `
<ul>
<li><a href="/p1/"><b><span>Page 1</span></b></a></li>
<li><a href="/p2/"><b><span>Page 2</span></b></a></li>
</ul>
`)
}
func TestDecoratorMultiple(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
title = "Test title"
-- layouts/home.html --
{{ with partial "d1.html" . }}X2{{ . }}X4{{ end }}
-- layouts/_partials/d1.html --
X1{{ inner "X3" }}X5
{{ with partial "d2.html" . }}X7{{ . }}X9{{ end }}
{{ with partial "noinner.html" "N3" }}N1{{ . }}N5{{ end }}
X14{{ inner "X15" }}X16
-- layouts/_partials/d2.html --
X6{{ inner "X8" }}X10
{{ with partial "d3.html" . }}A1{{ . }}A2{{ end }}
X11{{ inner "X12" }}X13
-- layouts/_partials/d3.html --
A3{{ inner "A4" }}A5
A6{{ inner "A7" }}A8
-- layouts/_partials/noinner.html --
N2{{ . }}N4
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/index.html",
"X1X2X3X4X5",
"X6X7X8X9X10",
"X11X7X12X9X13",
"X14X2X15X4X16",
"A3A1A4A2A5",
"N1N2N3N4N5", // partial with with, but no inner.
)
}
func TestDecoratorEditInner(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
baseURL = "http://example.org/"
disableLiveReload = true
-- layouts/_partials/a.html --
<b>{{ inner . }}</b>
-- layouts/home.html --
{{ with partial "a.html" "Hello" }}{{ . }} World0{{ end }}$
`
b := hugolib.TestRunning(t, files)
b.AssertFileContent("public/index.html",
"<b>Hello World0</b>$",
)
for i := range 4 {
b.EditFileReplaceAll("layouts/home.html", fmt.Sprintf("World%d", i), fmt.Sprintf("World%d", i+1)).Build()
b.AssertFileContent("public/index.html")
}
}
func TestDecoratorEditPartial(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
baseURL = "http://example.org/"
disableLiveReload = true
-- layouts/_partials/a.html --
<b>{{ inner (printf "%s World0" .) }}</b>
-- layouts/home.html --
{{ with partial "a.html" "Hello" }}{{ . }}{{ end }}$
`
b := hugolib.TestRunning(t, files)
b.AssertFileContent("public/index.html",
"<b>Hello World0</b>$",
)
for i := range 4 {
b.EditFileReplaceAll("layouts/_partials/a.html", fmt.Sprintf("World%d", i), fmt.Sprintf("World%d", i+1)).Build()
b.AssertFileContent("public/index.html")
}
}
func TestDecoratorDuplicateInner(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
-- layouts/_partials/a.html --
<b>{{ inner . }}</b>
-- layouts/home.html --
1: {{ with partial "a.html" "Hello" }}{{ . }}{{ end }}$
2: {{ with partial "a.html" "World" }}{{ . }}{{ end }}$
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/index.html",
"1: <b>Hello</b>$",
"2: <b>World</b>$",
)
}
func TestDecoratorInAllTemplateTypes(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
-- layouts/_partials/b.html --
<b>{{ inner . }}</b>
-- layouts/_markup/render-link.html --
{{ with partial "b.html" "hello" }}{{ . }} world{{ end }}
-- layouts/_shortcodes/a.html --
{{ with partial "b.html" (.Get 0) }}{{ . }} world{{ end }}
-- layouts/_partials/a.html --
{{ with partial "b.html" . }}{{ . }} world{{ end }}
-- layouts/home.html --
partial: {{ partial "a.html" "hello" }}$
{{ .Content}}
-- content/_index.md --
---
title: "Home"
---
shortcode: {{< a "hello" >}}$
link: [example](/some-url)$
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/index.html",
"partial: <b>hello world</b>$",
"shortcode: <b>hello world</b>$",
"link: <b>hello world</b>$</p>",
)
}
func TestDecoratorInAllPartialFuncNames(t *testing.T) {
t.Parallel()
filesTemplate := `
-- hugo.toml --
-- layouts/_partials/b.html --
<b>{{ inner . }}</b>
-- layouts/home.html --
{{ with FUNC "b.html" "hello" }}{{ . }} world{{ end }}$
`
for _, partialFunc := range []string{"partial", "partialCached", "partials.Include", "partials.IncludeCached"} {
files := strings.ReplaceAll(filesTemplate, "FUNC", partialFunc)
b := hugolib.Test(t, files)
b.AssertFileContent("public/index.html",
"<b>hello world</b>$",
)
}
}
func TestDecoratorReturn(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
-- layouts/_partials/add.html --
{{ $sum := .sum }}
{{ $sum = add $sum (inner 1) }}
{{ $sum = add $sum (inner 2) }}
{{ $sum = add $sum (inner 3) }}
{{ return $sum }}
-- layouts/home.html --
{{ $v := dict "sum" 1 }}
Sum: {{ with partial "add.html" $v }}
{{ $sum := mul . 2 }}
{{ return $sum }}
{{ end }}$
`
b := hugolib.Test(t, files)
// .sum = 1
// inner 1 => 2
// inner 2 => 4
// inner 3 => 6
// 1 + 2 + 4 + 6 = 13
b.AssertFileContent("public/index.html", "Sum: 13$")
}
func TestDecoratorFailOnInnerInWith(t *testing.T) {
t.Parallel()
filesTemplate := `
-- hugo.toml --
-- layouts/_partials/b.html --
<b>{{ inner . }}</b>
-- layouts/home.html --
{{ with partial "b.html" "hello" }}
This construct creates a loop: {{PLACEHOLDER . }}
{{ end }}$
`
for _, placeholder := range []string{"inner", "templates.Inner", " inner", "\ninner"} {
files := strings.ReplaceAll(filesTemplate, "PLACEHOLDER", placeholder)
b, err := hugolib.TestE(t, files)
b.Assert(err, qt.Not(qt.IsNil))
b.Assert(err.Error(), qt.Contains, "inner cannot be used inside a with block that wraps a partial decorator")
}
}
+31
View File
@@ -18,6 +18,7 @@ import (
"github.com/gohugoio/hugo/deps"
"github.com/gohugoio/hugo/tpl/internal"
"github.com/gohugoio/hugo/tpl/partials"
)
const name = "templates"
@@ -29,6 +30,19 @@ func init() {
ns := &internal.TemplateFuncsNamespace{
Name: name,
Context: func(cctx context.Context, args ...any) (any, error) { return ctx, nil },
OnCreated: func(m map[string]any) {
LOOP:
for _, v := range m {
switch v := v.(type) {
case *partials.Namespace:
ctx.partialsNs = v
break LOOP
}
}
if ctx.partialsNs == nil {
panic("partialsNs namespace not found")
}
},
}
ns.AddMethodMapping(ctx.Current,
@@ -47,6 +61,23 @@ func init() {
[][2]string{},
)
ns.AddMethodMapping(ctx.Inner,
[]string{"inner"},
[][2]string{},
)
// For internal use only.
ns.AddMethodMapping(ctx._PushPartialDecorator,
[]string{"_pushPartialDecorator"},
[][2]string{},
)
// For internal use only.
ns.AddMethodMapping(ctx._PopPartialDecorator,
[]string{"_popPartialDecorator"},
[][2]string{},
)
ns.AddMethodMapping(ctx.Exists,
nil,
[][2]string{
+45 -1
View File
@@ -20,9 +20,12 @@ import (
"strconv"
"sync/atomic"
"github.com/gohugoio/hugo/tpl/tplimpl"
"github.com/gohugoio/hugo/common/hashing"
"github.com/gohugoio/hugo/deps"
"github.com/gohugoio/hugo/tpl"
"github.com/gohugoio/hugo/tpl/partials"
"github.com/mitchellh/mapstructure"
)
@@ -37,7 +40,8 @@ func New(deps *deps.Deps) *Namespace {
// Namespace provides template functions for the "templates" namespace.
type Namespace struct {
deps *deps.Deps
deps *deps.Deps
partialsNs *partials.Namespace
}
// Exists returns whether the template with the given name exists.
@@ -70,6 +74,46 @@ type DeferOpts struct {
Data any
}
// Inner executes the inner content of a partial decorator.
// Note that there is only one inner block per partial decorator, but inner may be called multiple times with, typically, different data.
func (ns *Namespace) Inner(ctx context.Context, data any) (any, error) {
stack := tpl.Context.PartialDecoratorIDStack.Get(ctx)
id, ok := stack.Peek()
if !ok {
panic("no partial decorator ID on stack")
}
// Signal that inner exists.
id.Bool = true
partialName := fmt.Sprintf("%s%s", tplimpl.PartialDecoratorPrefix, id.Str)
v, err := ns.partialsNs.Include(ctx, partialName, data)
return v, err
}
// For internal use only.
func (ns *Namespace) _PushPartialDecorator(ctx context.Context, id string) (any, error) {
tpl.Context.PartialDecoratorIDStack.Get(ctx).Push(&tpl.StringBool{Str: id, Bool: false})
return "", nil
}
// For internal use only.
func (ns *Namespace) _PopPartialDecorator(ctx context.Context, id string) bool {
stack := tpl.Context.PartialDecoratorIDStack.Get(ctx)
if stack == nil || stack.Len() == 0 {
panic("decorator stack is nil or empty")
}
// The stack is tied to the context, so no data race.
top, ok := stack.Pop()
if !ok || top.Str != id {
panic("partial decorator ID mismatch")
}
return top.Bool // return whether inner exists in the wrapped partial.
}
// DoDefer defers the execution of a template block.
// For internal use only.
func (ns *Namespace) DoDefer(ctx context.Context, id string, optsv any) string {
+3
View File
@@ -21,6 +21,9 @@ type ParseInfo struct {
// Set for shortcode templates with any {{ .Inner }}
IsInner bool
// Set for partial templates with any {{ inner }} or {{ templates.Inner }}
HasPartialInner bool
// Set for partials with a return statement.
HasReturn bool
+16
View File
@@ -53,6 +53,22 @@ func (s *TemplateStore) parseTemplate(ti *TemplInfo, replace bool) error {
return err
}
func (t *templateNamespace) newBlankTemplate(ti *TemplInfo) tpl.Template {
if ti.D.IsPlainText {
tt, err := t.parseText.New(ti.Name()).Parse("")
if err != nil {
panic(err)
}
return tt
}
tt, err := t.parseHTML.New(ti.Name()).Parse("")
if err != nil {
panic(err)
}
return tt
}
func (t *templateNamespace) doParseTemplate(ti *TemplInfo, replace bool) error {
if !ti.noBaseOf || ti.category == CategoryBaseof {
// Delay parsing until we have the base template.
+30 -1
View File
@@ -35,6 +35,7 @@ import (
"sync/atomic"
"time"
"github.com/gohugoio/hugo/common/collections"
"github.com/gohugoio/hugo/common/herrors"
"github.com/gohugoio/hugo/common/hstrings"
"github.com/gohugoio/hugo/common/loggers"
@@ -489,6 +490,15 @@ func (s *TemplateStore) FindAllBaseTemplateCandidates(overlayKey string, d1 Temp
return result
}
// PrepareTopLevelRenderCtx prepares a context for top-level rendering of a page.
func (t *TemplateStore) PrepareTopLevelRenderCtx(ctx context.Context, p page.Page) context.Context {
if p != nil {
ctx = tpl.Context.Page.Set(ctx, p)
}
ctx = tpl.Context.PartialDecoratorIDStack.Set(ctx, collections.NewStack[*tpl.StringBool]())
return ctx
}
func (t *TemplateStore) ExecuteWithContext(ctx context.Context, ti *TemplInfo, wr io.Writer, data any) error {
return t.ExecuteWithContextAndKey(ctx, "", ti, wr, data)
}
@@ -1262,6 +1272,7 @@ func (s *TemplateStore) insertTemplate2(
D: d,
matrix: matrix,
category: category,
subCategory: subCategory,
noBaseOf: category > CategoryLayout,
isLegacyMapped: isLegacyMapped,
}
@@ -1570,6 +1581,24 @@ func (s *TemplateStore) createTemplatesSnapshot() error {
return nil
}
func (s *TemplateStore) addTransformedTemplateInsert(name string, subCategory SubCategory) (*TemplInfo, error) {
pi := s.opts.PathParser.Parse(files.ComponentFolderLayouts, name)
ti, err := s.insertTemplate(pi, nil, subCategory, true, s.treeMain)
if err != nil {
return nil, err
}
return ti, nil
}
func (s *TemplateStore) addTransformedTemplateSetTree(this *TemplInfo, root *parse.ListNode) (*parse.Tree, error) {
templ := s.tns.newBlankTemplate(this)
tree := getParseTree(templ)
tree.Root = root
this.Template = templ
this.state = processingStateTransformed
return tree, nil
}
func (s *TemplateStore) parseTemplates(replace bool) error {
if err := func() error {
// Read and parse all templates.
@@ -1858,7 +1887,7 @@ func (s *TemplateStore) transformTemplates() error {
if vv.category == CategoryBaseof {
continue
}
tctx, err := applyTemplateTransformers(vv, lookup)
tctx, err := applyTemplateTransformers(vv, s, lookup)
if err != nil {
return err
}
+177 -21
View File
@@ -3,6 +3,7 @@ package tplimpl
import (
"errors"
"fmt"
"regexp"
"slices"
"strings"
@@ -22,6 +23,7 @@ type templateTransformContext struct {
templateNotFound map[string]bool
deferNodes map[string]*parse.ListNode
lookupFn func(name string, in *TemplInfo) *TemplInfo
store *TemplateStore
// The last error encountered.
err error
@@ -53,11 +55,13 @@ func (c templateTransformContext) getIfNotVisited(name string) *TemplInfo {
func newTemplateTransformContext(
t *TemplInfo,
store *TemplateStore,
lookupFn func(name string, in *TemplInfo) *TemplInfo,
) *templateTransformContext {
return &templateTransformContext{
t: t,
lookupFn: lookupFn,
store: store,
visited: make(map[string]bool),
templateNotFound: make(map[string]bool),
deferNodes: make(map[string]*parse.ListNode),
@@ -66,28 +70,25 @@ func newTemplateTransformContext(
func applyTemplateTransformers(
t *TemplInfo,
store *TemplateStore,
lookupFn func(name string, in *TemplInfo) *TemplInfo,
) (*templateTransformContext, error) {
if t == nil {
return nil, errors.New("expected template, but none provided")
}
c := newTemplateTransformContext(t, lookupFn)
c := newTemplateTransformContext(t, store, lookupFn)
c.t.ParseInfo = defaultParseInfo
tree := getParseTree(t.Template)
if tree == nil {
panic(fmt.Errorf("template %s not parsed", t))
}
_, err := c.applyTransformations(tree.Root)
if err == nil && c.returnNode != nil {
// This is a partial with a return statement.
c.t.ParseInfo.HasReturn = true
tree.Root = c.wrapInPartialReturnWrapper(tree.Root)
if err := c.applyTransformationsAndSetReturnWrapper(tree); err != nil {
return c, fmt.Errorf("failed to transform template %q: %w", t.Name(), err)
}
return c, err
return c, c.err
}
func getParseTree(templ tpl.Template) *parse.Tree {
@@ -105,11 +106,17 @@ const (
partialReturnWrapperTempl = `{{ $_hugo_dot := $ }}{{ $ := .Arg }}{{ range (slice .Arg) }}{{ $_hugo_dot.Set ("PLACEHOLDER") }}{{ end }}`
doDeferTempl = `{{ doDefer ("PLACEHOLDER1") ("PLACEHOLDER2") }}`
// _pushPartialDecorator is always falsy.
pushPartialDecoratorTempl = `{{ if or (_pushPartialDecorator ("PLACEHOLDER")) }}{{ end }}`
popPartialDecoratorTempl = `{{ if (_popPartialDecorator ("PLACEHOLDER1")) }}{{ . }}{{ else }}("PLACEHOLDER2"){{ end }}`
)
var (
partialReturnWrapper *parse.ListNode
doDefer *parse.ListNode
popPartialDecorator *parse.ListNode
pushPartialDecorator *parse.ListNode
)
func init() {
@@ -124,6 +131,18 @@ func init() {
panic(err)
}
doDefer = templ.Tree.Root
templ, err = texttemplate.New("").Funcs(texttemplate.FuncMap{"_popPartialDecorator": func(string) string { return "" }}).Parse(popPartialDecoratorTempl)
if err != nil {
panic(err)
}
popPartialDecorator = templ.Tree.Root
templ, err = texttemplate.New("").Funcs(texttemplate.FuncMap{"_pushPartialDecorator": func(string) string { return "" }}).Parse(pushPartialDecoratorTempl)
if err != nil {
panic(err)
}
pushPartialDecorator = templ.Tree.Root
}
// wrapInPartialReturnWrapper copies and modifies the parsed nodes of a
@@ -142,7 +161,20 @@ func (c *templateTransformContext) wrapInPartialReturnWrapper(n *parse.ListNode)
return wrapper
}
// applyTransformations do 2 things:
func (c *templateTransformContext) applyTransformationsAndSetReturnWrapper(tree *parse.Tree) error {
_, err := c.applyTransformations(tree.Root)
if err != nil {
return err
}
if c.returnNode != nil {
// This is a partial with a return statement.
c.t.ParseInfo.HasReturn = true
tree.Root = c.wrapInPartialReturnWrapper(tree.Root)
}
return nil
}
// applyTransformations does 2 things:
// 1) Parses partial return statement.
// 2) Tracks template (partial) dependencies and some other info.
func (c *templateTransformContext) applyTransformations(n parse.Node) (bool, error) {
@@ -156,7 +188,7 @@ func (c *templateTransformContext) applyTransformations(n parse.Node) (bool, err
case *parse.IfNode:
c.applyTransformationsToNodes(x.Pipe, x.List, x.ElseList)
case *parse.WithNode:
c.handleDefer(x)
c.handleWith(x)
c.applyTransformationsToNodes(x.Pipe, x.List, x.ElseList)
case *parse.RangeNode:
c.applyTransformationsToNodes(x.Pipe, x.List, x.ElseList)
@@ -178,7 +210,8 @@ func (c *templateTransformContext) applyTransformations(n parse.Node) (bool, err
if x == nil {
return true, nil
}
c.collectInner(x)
c.collectInnerInShortcode(x)
c.collectInnerInPartial(x)
keep := c.collectReturnNode(x)
for _, elem := range x.Args {
@@ -193,14 +226,118 @@ func (c *templateTransformContext) applyTransformations(n parse.Node) (bool, err
return true, c.err
}
func (c *templateTransformContext) handleDefer(withNode *parse.WithNode) {
func (c *templateTransformContext) isWithPartial(args []parse.Node) bool {
if len(args) == 0 {
return false
}
if id1, ok := args[0].(*parse.IdentifierNode); ok && (id1.Ident == "partial" || id1.Ident == "partialCached") {
return true
}
if chain, ok := args[0].(*parse.ChainNode); ok {
if id2, ok := chain.Node.(*parse.IdentifierNode); !ok || (id2.Ident != "partials") {
return false
}
if len(chain.Field) != 1 {
return false
}
if chain.Field[0] != "Include" && chain.Field[0] != "IncludeCached" {
return false
}
return true
}
return false
}
func (c *templateTransformContext) isWithDefer(idArg parse.Node) bool {
id, ok := idArg.(*parse.ChainNode)
if !ok || len(id.Field) != 1 || id.Field[0] != "Defer" {
return false
}
if id2, ok := id.Node.(*parse.IdentifierNode); !ok || id2.Ident != "templates" {
return false
}
return true
}
// PartialDecoratorPrefix is the prefix used for internal partial decorator templates.
const PartialDecoratorPrefix = "_internal/decorator_"
var templatesInnerRe = regexp.MustCompile(`{{\s*(templates\.Inner\b|inner\b)`)
func (c *templateTransformContext) handleWithPartial(withNode *parse.WithNode) {
withNodeInnerString := withNode.List.String()
if templatesInnerRe.MatchString(withNodeInnerString) {
c.err = fmt.Errorf("inner cannot be used inside a with block that wraps a partial decorator")
return
}
innerHash := hashing.XxHashFromStringHexEncoded(c.t.Name() + withNodeInnerString)
internalPartialName := fmt.Sprintf("_partials/%s%s", PartialDecoratorPrefix, innerHash)
if c.lookupFn(internalPartialName, c.t) == nil {
innerCopy := withNode.List.CopyList()
ti, err := c.store.addTransformedTemplateInsert(internalPartialName, SubCategoryInline)
if err != nil {
c.err = fmt.Errorf("failed to create internal partial decorator template %q: %w", internalPartialName, err)
return
}
if ti == nil {
c.err = fmt.Errorf("failed to find internal partial decorator template %q after insertion", internalPartialName)
return
}
cc := newTemplateTransformContext(ti, c.store, c.lookupFn)
tree, err := c.store.addTransformedTemplateSetTree(ti, innerCopy)
if err != nil {
c.err = fmt.Errorf("failed to add internal partial decorator template %q: %w", internalPartialName, err)
return
}
if err := cc.applyTransformationsAndSetReturnWrapper(tree); err != nil {
c.err = fmt.Errorf("failed to transform internal partial decorator template %q: %w", internalPartialName, err)
return
}
}
newInner := popPartialDecorator.CopyList()
ifNode := newInner.Nodes[0].(*parse.IfNode)
placeholderPipe := ifNode.Pipe.Cmds[0].Args[0].(*parse.PipeNode)
// Set PLACEHOLDER1 to the unique ID for this partial decorator.
sn1 := placeholderPipe.Cmds[0].Args[1].(*parse.PipeNode).Cmds[0].Args[0].(*parse.StringNode)
sn1.Text = innerHash
sn1.Quoted = fmt.Sprintf("%q", sn1.Text)
ifNode.ElseList = withNode.List.CopyList()
newPipe := pushPartialDecorator.CopyList()
orNode := newPipe.Nodes[0].(*parse.IfNode)
setContext := orNode.Pipe.Cmds[0].Args[1]
// Replace PLACEHOLDER with the unique ID for this partial decorator.
sn2 := setContext.(*parse.PipeNode).Cmds[0].Args[1].(*parse.PipeNode).Cmds[0].Args[0].(*parse.StringNode)
sn2.Text = innerHash
sn2.Quoted = fmt.Sprintf("%q", sn2.Text)
withNode.Pipe.Cmds = append(orNode.Pipe.Cmds, withNode.Pipe.Cmds...)
withNode.List = newInner
}
func (c *templateTransformContext) handleWith(withNode *parse.WithNode) {
if len(withNode.Pipe.Cmds) != 1 {
return
}
cmd := withNode.Pipe.Cmds[0]
if len(cmd.Args) != 1 {
if c.isWithPartial(withNode.Pipe.Cmds[0].Args) {
c.handleWithPartial(withNode)
return
}
cmd := withNode.Pipe.Cmds[0]
idArg := cmd.Args[0]
p, ok := idArg.(*parse.PipeNode)
@@ -220,11 +357,7 @@ func (c *templateTransformContext) handleDefer(withNode *parse.WithNode) {
idArg = cmd.Args[0]
id, ok := idArg.(*parse.ChainNode)
if !ok || len(id.Field) != 1 || id.Field[0] != "Defer" {
return
}
if id2, ok := id.Node.(*parse.IdentifierNode); !ok || id2.Ident != "templates" {
if !c.isWithDefer(idArg) {
return
}
@@ -304,9 +437,9 @@ func (c *templateTransformContext) collectConfig(n *parse.PipeNode) {
}
}
// collectInner determines if the given CommandNode represents a
// collectInnerInShortcode determines if the given CommandNode represents a
// shortcode call to its .Inner.
func (c *templateTransformContext) collectInner(n *parse.CommandNode) {
func (c *templateTransformContext) collectInnerInShortcode(n *parse.CommandNode) {
if c.t.category != CategoryShortcode {
return
}
@@ -330,6 +463,29 @@ func (c *templateTransformContext) collectInner(n *parse.CommandNode) {
}
}
func (c *templateTransformContext) collectInnerInPartial(n *parse.CommandNode) {
if c.t.category != CategoryPartial {
return
}
if c.t.ParseInfo.HasPartialInner || len(n.Args) == 0 {
return
}
switch v := n.Args[0].(type) {
case *parse.IdentifierNode:
if v.Ident == "inner" {
c.t.ParseInfo.HasPartialInner = true
}
case *parse.ChainNode:
if v.Field[0] == "Inner" {
if id, ok := v.Node.(*parse.IdentifierNode); ok && id.Ident == "templates" {
c.t.ParseInfo.HasPartialInner = true
}
}
}
}
func (c *templateTransformContext) collectReturnNode(n *parse.CommandNode) bool {
if c.t.category != CategoryPartial || c.returnNode != nil {
return true