Repository: gohugoio/hugo (language: go)

Issue:
Adapter content is not creating related pages by keywords
<!--
Please do not use the issue queue for questions or troubleshooting. Unless you are certain that your issue is a software defect, use the forum:

https://discourse.gohugo.io
-->

<!-- Please answer these questions before submitting your issue. Thanks! -->

### What version of Hugo are you using (`hugo version`)?

<pre>
hugo v0.141.0-e7bd51698e5c3778a86003018702b1a7dcb9559a+extended linux/amd64 BuildDate=2025-01-16T13:11:18Z VendorInfo=gohugoio
</pre>

### Does this issue reproduce with the latest release?
I don't know

---

👋 First of all I must say that I LOVE hugo, thanks for this amazing project.

Let's get to the point, recently I have migrated my hugo to content adapters and I found that when using a content adapter the related pages are not created by keywords anymore.

If I copy keywords to params.tags it works again.

I'm using hugo default configuration for related content.

I create a page with this:
```
{{ $page := dict
    "kind" "page"
    "title" $post.title
    "dates" $dates
    "keywords" "one"
    "content" $content
    "path" $postPath
}}
```

As keywords create related content in markdown I suppose that this is a bug.



Discussion / hints:
> "keywords" "one"

I'm not saying this is what happens, but `keywords` is a slice, so `"keywords" (slice "one")` would be something to try.

You could verify by just printing the Page keywords in a template, e.g. `{{ .Keywords }}`
Assuming this front matter: `keywords = ['a','b','c']`

With pages created manually:

```text
{{ .Keywords }} →        [a b c]
{{ .Params.keywords }} → [a b c]
{{ .Params.Keywords }} → [a b c]
```

With pages created by a content adapter:

```text
{{ .Keywords }} →        [a b c]
{{ .Params.keywords }} → 
{{ .Params.Keywords }} → 
```

The related pages config should be keying off of "Keywords" (capitalized), but either "Keywords" or "keywords" works with pages created manually.

```text
[[related.indices]]
name = 'Keywords'
weight = 80
```

Perhaps we lowercase the index name internally? Because if I  change the content adapter to do this...

```text
"params" (dict "keywords" (slice "a" "b" "c"))
```

... the related pages feature works as expected.








OK, there's obviously a bug or two here ... I'll have a closer look.

Fix patch:
diff --git a/hugolib/page.go b/hugolib/page.go
index 3653767379a..701e0f11bca 100644
--- a/hugolib/page.go
+++ b/hugolib/page.go
@@ -151,6 +151,20 @@ func (p *pageState) Key() string {
 	return "page-" + strconv.FormatUint(p.pid, 10)
 }
 
+// RelatedKeywords implements the related.Document interface needed for fast page searches.
+func (p *pageState) RelatedKeywords(cfg related.IndexConfig) ([]related.Keyword, error) {
+	v, found, err := page.NamedPageMetaValue(p, cfg.Name)
+	if err != nil {
+		return nil, err
+	}
+
+	if !found {
+		return nil, nil
+	}
+
+	return cfg.ToKeywords(v)
+}
+
 func (p *pageState) resetBuildState() {
 	// Nothing to do for now.
 }
diff --git a/hugolib/page__common.go b/hugolib/page__common.go
index a120849b380..7407f7140cc 100644
--- a/hugolib/page__common.go
+++ b/hugolib/page__common.go
@@ -68,7 +68,6 @@ type pageCommon struct {
 	page.PageMetaInternalProvider
 	page.Positioner
 	page.RawContentProvider
-	page.RelatedKeywordsProvider
 	page.RefProvider
 	page.ShortcodeInfoProvider
 	page.SitesProvider
diff --git a/hugolib/page__meta.go b/hugolib/page__meta.go
index 56bc1d96fbf..e44224bae9d 100644
--- a/hugolib/page__meta.go
+++ b/hugolib/page__meta.go
@@ -27,8 +27,6 @@ import (
 	"github.com/gohugoio/hugo/markup/converter"
 	xmaps "golang.org/x/exp/maps"
 
-	"github.com/gohugoio/hugo/related"
-
 	"github.com/gohugoio/hugo/source"
 
 	"github.com/gohugoio/hugo/common/constants"
@@ -215,16 +213,6 @@ func (p *pageMeta) PathInfo() *paths.Path {
 	return p.pathInfo
 }
 
-// RelatedKeywords implements the related.Document interface needed for fast page searches.
-func (p *pageMeta) RelatedKeywords(cfg related.IndexConfig) ([]related.Keyword, error) {
-	v, err := p.Param(cfg.Name)
-	if err != nil {
-		return nil, err
-	}
-
-	return cfg.ToKeywords(v)
-}
-
 func (p *pageMeta) IsSection() bool {
 	return p.Kind() == kinds.KindSection
 }
diff --git a/hugolib/page__new.go b/hugolib/page__new.go
index 7d948ef5882..b8c3483add6 100644
--- a/hugolib/page__new.go
+++ b/hugolib/page__new.go
@@ -202,7 +202,6 @@ func (h *HugoSites) doNewPage(m *pageMeta) (*pageState, *paths.Path, error) {
 				ResourceParamsProvider:    m,
 				PageMetaProvider:          m,
 				PageMetaInternalProvider:  m,
-				RelatedKeywordsProvider:   m,
 				OutputFormatsProvider:     page.NopPage,
 				ResourceTypeProvider:      pageTypesProvider,
 				MediaTypeProvider:         pageTypesProvider,
diff --git a/related/inverted_index.go b/related/inverted_index.go
index 7e171cf539e..9197a61351c 100644
--- a/related/inverted_index.go
+++ b/related/inverted_index.go
@@ -582,6 +582,9 @@ func DecodeConfig(m maps.Params) (Config, error) {
 		}
 	}
 	for i := range c.Indices {
+		// Lower case name.
+		c.Indices[i].Name = strings.ToLower(c.Indices[i].Name)
+
 		icfg := c.Indices[i]
 		if icfg.Type == "" {
 			c.Indices[i].Type = TypeBasic
diff --git a/resources/page/page.go b/resources/page/page.go
index 40e15dc6f58..e9ecf26d791 100644
--- a/resources/page/page.go
+++ b/resources/page/page.go
@@ -180,6 +180,11 @@ type PageFragment interface {
 	resource.ResourceNameTitleProvider
 }
 
+type PageMetaResource interface {
+	PageMetaProvider
+	resource.Resource
+}
+
 // PageMetaProvider provides page metadata, typically provided via front matter.
 type PageMetaProvider interface {
 	// The 4 page dates
@@ -251,6 +256,68 @@ type PageMetaProvider interface {
 	Weight() int
 }
 
+// NamedPageMetaValue returns a named metadata value from a PageMetaResource.
+// This is currently only used to generate keywords for related content.
+// If nameLower is not one of the metadata interface methods, we
+// look in Params.
+func NamedPageMetaValue(p PageMetaResource, nameLower string) (any, bool, error) {
+	var (
+		v   any
+		err error
+	)
+
+	switch nameLower {
+	case "kind":
+		v = p.Kind()
+	case "bundletype":
+		v = p.BundleType()
+	case "mediatype":
+		v = p.MediaType()
+	case "section":
+		v = p.Section()
+	case "lang":
+		v = p.Lang()
+	case "aliases":
+		v = p.Aliases()
+	case "name":
+		v = p.Name()
+	case "keywords":
+		v = p.Keywords()
+	case "description":
+		v = p.Description()
+	case "title":
+		v = p.Title()
+	case "linktitle":
+		v = p.LinkTitle()
+	case "slug":
+		v = p.Slug()
+	case "date":
+		v = p.Date()
+	case "publishdate":
+		v = p.PublishDate()
+	case "expirydate":
+		v = p.ExpiryDate()
+	case "lastmod":
+		v = p.Lastmod()
+	case "draft":
+		v = p.Draft()
+	case "type":
+		v = p.Type()
+	case "layout":
+		v = p.Layout()
+	case "weight":
+		v = p.Weight()
+	default:
+		// Try params.
+		v, err = resource.Param(p, nil, nameLower)
+		if v == nil {
+			return nil, false, nil
+		}
+	}
+
+	return v, err == nil, err
+}
+
 // PageMetaInternalProvider provides internal page metadata.
 type PageMetaInternalProvider interface {
 	// This is for internal use only.


Test patch:
diff --git a/hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go b/hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go
index e1d562d4d21..b674ef0b6cd 100644
--- a/hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go
+++ b/hugolib/pagesfromdata/pagesfromgotmpl_integration_test.go
@@ -1,4 +1,4 @@
-// Copyright 2024 The Hugo Authors. All rights reserved.
+// 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.
@@ -23,6 +23,7 @@ import (
 	"github.com/gohugoio/hugo/markup/asciidocext"
 	"github.com/gohugoio/hugo/markup/pandoc"
 	"github.com/gohugoio/hugo/markup/rst"
+	"github.com/gohugoio/hugo/related"
 )
 
 const filesPagesFromDataTempleBasic = `
@@ -73,10 +74,11 @@ Pfile Content
 {{ $title := printf "%s:%s" $pd $pp }}
 {{ $date := "2023-03-01" | time.AsTime }}
 {{ $dates := dict "date" $date }}
+{{ $keywords := slice "foo" "Bar"}}
 {{ $contentMarkdown := dict "value" "**Hello World**"  "mediaType" "text/markdown" }}
 {{ $contentMarkdownDefault := dict "value" "**Hello World Default**" }}
 {{ $contentHTML := dict "value" "<b>Hello World!</b> No **markdown** here." "mediaType" "text/html" }}
-{{ $.AddPage  (dict "kind" "page" "path" "P1" "title" $title "dates" $dates "content" $contentMarkdown "params" (dict "param1" "param1v" ) ) }}
+{{ $.AddPage  (dict "kind" "page" "path" "P1" "title" $title "dates" $dates "keywords" $keywords "content" $contentMarkdown "params" (dict "param1" "param1v" ) ) }}
 {{ $.AddPage  (dict "kind" "page" "path" "p2" "title" "p2title" "dates" $dates "content" $contentHTML ) }}
 {{ $.AddPage  (dict "kind" "page" "path" "p3" "title" "p3title" "dates" $dates "content" $contentMarkdownDefault "draft" false ) }}
 {{ $.AddPage  (dict "kind" "page" "path" "p4" "title" "p4title" "dates" $dates "content" $contentMarkdownDefault "draft" $data.draft ) }}
@@ -329,6 +331,24 @@ func TestPagesFromGoTmplRemoveGoTmpl(t *testing.T) {
 	b.AssertFileContent("public/docs/index.html", "RegularPagesRecursive: pfile:/docs/pfile|$")
 }
 
+// Issue #13443.
+func TestPagesFromGoRelatedKeywords(t *testing.T) {
+	t.Parallel()
+	b := hugolib.Test(t, filesPagesFromDataTempleBasic)
+
+	p1 := b.H.Sites[0].RegularPages()[0]
+	icfg := related.IndexConfig{
+		Name: "keywords",
+	}
+	k, err := p1.RelatedKeywords(icfg)
+	b.Assert(err, qt.IsNil)
+	b.Assert(k, qt.DeepEquals, icfg.StringsToKeywords("foo", "Bar"))
+	icfg.Name = "title"
+	k, err = p1.RelatedKeywords(icfg)
+	b.Assert(err, qt.IsNil)
+	b.Assert(k, qt.DeepEquals, icfg.StringsToKeywords("p1:p1"))
+}
+
 func TestPagesFromGoTmplLanguagePerFile(t *testing.T) {
 	filesTemplate := `
 -- hugo.toml --
diff --git a/related/inverted_index_test.go b/related/inverted_index_test.go
index f1d8e11a14a..568486d1f4b 100644
--- a/related/inverted_index_test.go
+++ b/related/inverted_index_test.go
@@ -21,6 +21,7 @@ import (
 	"time"
 
 	qt "github.com/frankban/quicktest"
+	"github.com/gohugoio/hugo/config"
 )
 
 type testDoc struct {
@@ -249,6 +250,50 @@ func TestToKeywordsToLower(t *testing.T) {
 	})
 }
 
+func TestDecodeConfig(t *testing.T) {
+	c := qt.New(t)
+
+	configToml := `
+[related]
+  includeNewer = true
+  threshold = 32
+  toLower = false
+  [[related.indices]]
+    applyFilter = false
+    cardinalityThreshold = 0
+    name = 'KeyworDs'
+    pattern = ''
+    toLower = false
+    type = 'basic'
+    weight = 100
+  [[related.indices]]
+    applyFilter = true
+    cardinalityThreshold = 32
+    name = 'date'
+    pattern = ''
+    toLower = false
+    type = 'basic'
+    weight = 10
+  [[related.indices]]
+    applyFilter = false
+    cardinalityThreshold = 0
+    name = 'tags'
+    pattern = ''
+    toLower = false
+    type = 'fragments'
+    weight = 80
+`
+
+	m, err := config.FromConfigString(configToml, "toml")
+	c.Assert(err, qt.IsNil)
+	conf, err := DecodeConfig(m.GetParams("related"))
+
+	c.Assert(err, qt.IsNil)
+	c.Assert(conf.IncludeNewer, qt.IsTrue)
+	first := conf.Indices[0]
+	c.Assert(first.Name, qt.Equals, "keywords")
+}
+
 func TestToKeywordsAnySlice(t *testing.T) {
 	c := qt.New(t)
 	var config IndexConfig