Repository: gohugoio/hugo (language: go)

Issue:
Add a way to identify a non-Page HTML etc. resource
Reference: <https://discourse.gohugo.io/t/shortcode-for-iframe-breaking-at-update-hugo-122-x-123-8/48904>

Not sure if this is an enhancement or a bug.

The test below obviously passes with v0.122.0 because we blindly copied _content_ page resources (`.html`, .`htm`, `.adoc`, `.pdc`, `.org`, `.rst`, `.md`) when publishing the site.

By design we are no longer copying _content_ page resources, but I think site and theme authors should be able to publish them with `Permalink`, `RelPermalink`, and `Publish`.

Failing test:

```go
func TestFoo(t *testing.T) {
	t.Parallel()

	files := `
-- hugo.toml --
baseURL = 'https://example.org/'
disableKinds = ['home','rss','section','sitemap','taxonomy','term']
-- content/p1/index.md --
---
title: p1
---
-- content/p1/a.html --
<p>a</p>
-- content/p1/b.html --
<p>b</p>
-- content/p1/c.html --
<p>c</p>
-- layouts/_default/single.html --
|{{ (.Resources.Get "a.html").RelPermalink -}}
|{{ (.Resources.Get "b.html").Permalink -}}
|{{ (.Resources.Get "c.html").Publish }}
`

	b := hugolib.Test(t, files)

	b.AssertFileContent("public/p1/index.html", "|/p1/a.html|https://example.org/p1/b.html|")
	b.AssertFileContent("public/p1/a.html", "<p>a</p>")
	b.AssertFileContent("public/p1/b.html", "<p>b</p>")
	b.AssertFileContent("public/p1/c.html", "<p>c</p>")
}
```

The workaround is a bit ugly:

```text
{{ ((.Resources.Get "a.html").Content | resources.FromString "/p1/a.html").RelPermalink }}
{{ ((.Resources.Get "b.html").Content | resources.FromString "/p1/b.html").Permalink }}
{{ ((.Resources.Get "c.html").Content | resources.FromString "/p1/c.html").Publish }}
```


Discussion / hints:
* A content file inside a leaf bundle is a headless `Page` (no `RelPermalink`); this is how it have always behaved.
* A `Page` has no `Publish` method for good reason: A page can have shortcodes, can be rendered to multiple output formats, so the rendering/publishing needs to be coordinated.
* I have never seen this in the wild, and I suspect that the few that wants "the old" behaviour for HTML files inside bundles would be surprised if we somehow published these relative to the bundle permalink, so I'm not sure I want to spend time on that, either.
Understood. Is there something cleaner than this to just dump the file (and return Permalink/RelPermalink)?

```text
{{ ((.Resources.Get "a.html").Content | resources.FromString "/p1/a.html").RelPermalink }}
```

Or  this?

```text
{{ with .Resources.Get "a.html" }}
  {{ (.Content | resources.FromString .Path).RelPermalink }}
{{ end }}
```
>Is there something cleaner than this to just dump the file (and return Permalink/RelPermalink)?

... copy it into ... static?
OP's use case needs to organize these with the page. I'm recommending second example above, or renaming the resource (e.g., a.txt instead of a.htm).

<https://discourse.gohugo.io/t/shortcode-for-iframe-breaking-at-update-hugo-122-x-123-8/48904/9?u=jmooring>
I have reopened and relabeled this ... I don't know how to practically do this, but it would be a good idea if we had a way to say that a particular bundled (e.g. HTML) file should be handled as a regular simple resource with a mime type (as in: not a `Page`). 
Related to <https://github.com/gohugoio/hugo/issues/9197>.
just thinking out loud:

Using .Page.Resource with a page bundle

* a resource like image or text will show as type *resources.resourceAdapter and dump as `Staler`.

* whereas a page resource (.md/.html)* shows at type *hugolib.pageState and dumps as JSON PageObject

* in a page template the context (. or $) is shows a *hugolib.pageState  

both have a ResourceType of page but you may only call .Permalink on the latter one

So at least within the templating there's a different experience incl. inconsistency for users. According to the Docs `.Page.Resources` returns `resource.Resources` but it's not exactly like that (at least for end users) cause a Resource has a Publish method.

looks like there is some Go magic interfacing behind ... and somehow obfuscating the details...

**Summary:** 

maybe thinking a bit easy ;-)

If a page source file is processed by a _Template_ its a "Page"
If a page source file is processed using _Resource Methods_ its a "Page Resource" (not a Page) 

If a Resource is published - the Content is copied verbatim to the target file


This is essentially a critical issue for us, and has caused us to stop updating Hugo at 122.  We have hundreds of html files that are treated as page resources (in our case, these are generated plotly graphs).  And, then included in iframes for rendering.   Changing the extension just doesn't work since then the files don't render.

Putting everything in the static folder is really not a good solution for us, since we are trying to keep the graphs local with the data they are relative to.  

For us, even just turning off or controlling the extensions that are treated as pages would be enough, we don't have html files as content anywhere. 

@Whoome In case you aren't aware of this...

```text
{{ range .Resources.Match "**.html" }}
  {{ $publishPath := urls.JoinPath $.RelPermalink .Name }}
  {{ (.Content | resources.FromString $publishPath).Publish }}
{{ end }}
```

Putting the above in a single.html file will publish the HTML files.
No I wasn't aware of that.  I just need to drop that anywhere in the theme?  
> in a single.html file 

Whichever one renders the pages with the HTML resources.

Fix patch:
diff --git a/common/hreflect/helpers.go b/common/hreflect/helpers.go
index fc83165c7cb..4d7339b5bae 100644
--- a/common/hreflect/helpers.go
+++ b/common/hreflect/helpers.go
@@ -74,6 +74,16 @@ func IsTruthful(in any) bool {
 	}
 }
 
+// IsMap reports whether v is a map.
+func IsMap(v any) bool {
+	return reflect.ValueOf(v).Kind() == reflect.Map
+}
+
+// IsSlice reports whether v is a slice.
+func IsSlice(v any) bool {
+	return reflect.ValueOf(v).Kind() == reflect.Slice
+}
+
 var zeroType = reflect.TypeOf((*types.Zeroer)(nil)).Elem()
 
 // IsTruthfulValue returns whether the given value has a meaningful truth value.
diff --git a/config/allconfig/allconfig.go b/config/allconfig/allconfig.go
index 3c2eddcaba3..3021028fdfd 100644
--- a/config/allconfig/allconfig.go
+++ b/config/allconfig/allconfig.go
@@ -128,6 +128,9 @@ type Config struct {
 	// <docsmeta>{"identifiers": ["markup"] }</docsmeta>
 	Markup markup_config.Config `mapstructure:"-"`
 
+	// ContentTypes are the media types that's considered content in Hugo.
+	ContentTypes *config.ConfigNamespace[map[string]media.ContentTypeConfig, media.ContentTypes] `mapstructure:"-"`
+
 	// The mediatypes configuration section maps the MIME type (a string) to a configuration object for that type.
 	// <docsmeta>{"identifiers": ["mediatypes"], "refs": ["types:media:type"] }</docsmeta>
 	MediaTypes *config.ConfigNamespace[map[string]media.MediaTypeConfig, media.Types] `mapstructure:"-"`
@@ -433,7 +436,6 @@ func (c *Config) CompileConfig(logger loggers.Logger) error {
 		IgnoredLogs:         ignoredLogIDs,
 		KindOutputFormats:   kindOutputFormats,
 		DefaultOutputFormat: defaultOutputFormat,
-		ContentTypes:        media.DefaultContentTypes.FromTypes(c.MediaTypes.Config),
 		CreateTitle:         helpers.GetTitleFunc(c.TitleCaseStyle),
 		IsUglyURLSection:    isUglyURL,
 		IgnoreFile:          ignoreFile,
@@ -471,7 +473,6 @@ type ConfigCompiled struct {
 	ServerInterface     string
 	KindOutputFormats   map[string]output.Formats
 	DefaultOutputFormat output.Format
-	ContentTypes        media.ContentTypes
 	DisabledKinds       map[string]bool
 	DisabledLanguages   map[string]bool
 	IgnoredLogs         map[string]bool
@@ -839,7 +840,7 @@ func (c *Configs) Init() error {
 	c.Languages = languages
 	c.LanguagesDefaultFirst = languagesDefaultFirst
 
-	c.ContentPathParser = &paths.PathParser{LanguageIndex: languagesDefaultFirst.AsIndexSet(), IsLangDisabled: c.Base.IsLangDisabled, IsContentExt: c.Base.C.ContentTypes.IsContentSuffix}
+	c.ContentPathParser = &paths.PathParser{LanguageIndex: languagesDefaultFirst.AsIndexSet(), IsLangDisabled: c.Base.IsLangDisabled, IsContentExt: c.Base.ContentTypes.Config.IsContentSuffix}
 
 	c.configLangs = make([]config.AllProvider, len(c.Languages))
 	for i, l := range c.LanguagesDefaultFirst {
diff --git a/config/allconfig/alldecoders.go b/config/allconfig/alldecoders.go
index 60e57199940..0bf8508d96c 100644
--- a/config/allconfig/alldecoders.go
+++ b/config/allconfig/alldecoders.go
@@ -163,6 +163,15 @@ var allDecoderSetups = map[string]decodeWeight{
 			return err
 		},
 	},
+	"contenttypes": {
+		key:    "contenttypes",
+		weight: 100, // This needs to be decoded after media types.
+		decode: func(d decodeWeight, p decodeConfig) error {
+			var err error
+			p.c.ContentTypes, err = media.DecodeContentTypes(p.p.GetStringMap(d.key), p.c.MediaTypes.Config)
+			return err
+		},
+	},
 	"mediatypes": {
 		key: "mediatypes",
 		decode: func(d decodeWeight, p decodeConfig) error {
diff --git a/config/allconfig/configlanguage.go b/config/allconfig/configlanguage.go
index deec61449bb..6990a35904a 100644
--- a/config/allconfig/configlanguage.go
+++ b/config/allconfig/configlanguage.go
@@ -145,7 +145,7 @@ func (c ConfigLanguage) NewIdentityManager(name string, opts ...identity.Manager
 }
 
 func (c ConfigLanguage) ContentTypes() config.ContentTypesProvider {
-	return c.config.C.ContentTypes
+	return c.config.ContentTypes.Config
 }
 
 // GetConfigSection is mostly used in tests. The switch statement isn't complete, but what's in use.
diff --git a/media/config.go b/media/config.go
index e00837e5e08..e50d8499d44 100644
--- a/media/config.go
+++ b/media/config.go
@@ -71,11 +71,15 @@ func init() {
 		EmacsOrgMode:     Builtin.EmacsOrgModeType,
 	}
 
-	DefaultContentTypes.init()
+	DefaultContentTypes.init(nil)
 }
 
 var DefaultContentTypes ContentTypes
 
+type ContentTypeConfig struct {
+	// Empty for now.
+}
+
 // ContentTypes holds the media types that are considered content in Hugo.
 type ContentTypes struct {
 	HTML             Type
@@ -85,13 +89,36 @@ type ContentTypes struct {
 	ReStructuredText Type
 	EmacsOrgMode     Type
 
+	types Types
+
 	// Created in init().
-	types        Types
 	extensionSet map[string]bool
 }
 
-func (t *ContentTypes) init() {
-	t.types = Types{t.HTML, t.Markdown, t.AsciiDoc, t.Pandoc, t.ReStructuredText, t.EmacsOrgMode}
+func (t *ContentTypes) init(types Types) {
+	sort.Slice(t.types, func(i, j int) bool {
+		return t.types[i].Type < t.types[j].Type
+	})
+
+	if tt, ok := types.GetByType(t.HTML.Type); ok {
+		t.HTML = tt
+	}
+	if tt, ok := types.GetByType(t.Markdown.Type); ok {
+		t.Markdown = tt
+	}
+	if tt, ok := types.GetByType(t.AsciiDoc.Type); ok {
+		t.AsciiDoc = tt
+	}
+	if tt, ok := types.GetByType(t.Pandoc.Type); ok {
+		t.Pandoc = tt
+	}
+	if tt, ok := types.GetByType(t.ReStructuredText.Type); ok {
+		t.ReStructuredText = tt
+	}
+	if tt, ok := types.GetByType(t.EmacsOrgMode.Type); ok {
+		t.EmacsOrgMode = tt
+	}
+
 	t.extensionSet = make(map[string]bool)
 	for _, mt := range t.types {
 		for _, suffix := range mt.Suffixes() {
@@ -135,32 +162,6 @@ func (t ContentTypes) Types() Types {
 	return t.types
 }
 
-// FromTypes creates a new ContentTypes updated with the values from the given Types.
-func (t ContentTypes) FromTypes(types Types) ContentTypes {
-	if tt, ok := types.GetByType(t.HTML.Type); ok {
-		t.HTML = tt
-	}
-	if tt, ok := types.GetByType(t.Markdown.Type); ok {
-		t.Markdown = tt
-	}
-	if tt, ok := types.GetByType(t.AsciiDoc.Type); ok {
-		t.AsciiDoc = tt
-	}
-	if tt, ok := types.GetByType(t.Pandoc.Type); ok {
-		t.Pandoc = tt
-	}
-	if tt, ok := types.GetByType(t.ReStructuredText.Type); ok {
-		t.ReStructuredText = tt
-	}
-	if tt, ok := types.GetByType(t.EmacsOrgMode.Type); ok {
-		t.EmacsOrgMode = tt
-	}
-
-	t.init()
-
-	return t
-}
-
 // Hold the configuration for a given media type.
 type MediaTypeConfig struct {
 	// The file suffixes used for this media type.
@@ -169,6 +170,58 @@ type MediaTypeConfig struct {
 	Delimiter string
 }
 
+var defaultContentTypesConfig = map[string]ContentTypeConfig{
+	Builtin.HTMLType.Type:             {},
+	Builtin.MarkdownType.Type:         {},
+	Builtin.AsciiDocType.Type:         {},
+	Builtin.PandocType.Type:           {},
+	Builtin.ReStructuredTextType.Type: {},
+	Builtin.EmacsOrgModeType.Type:     {},
+}
+
+// DecodeContentTypes decodes the given map of content types.
+func DecodeContentTypes(in map[string]any, types Types) (*config.ConfigNamespace[map[string]ContentTypeConfig, ContentTypes], error) {
+	buildConfig := func(v any) (ContentTypes, any, error) {
+		var s map[string]ContentTypeConfig
+		c := DefaultContentTypes
+		m, err := maps.ToStringMapE(v)
+		if err != nil {
+			return c, nil, err
+		}
+		if len(m) == 0 {
+			s = defaultContentTypesConfig
+		} else {
+			s = make(map[string]ContentTypeConfig)
+			m = maps.CleanConfigStringMap(m)
+			for k, v := range m {
+				var ctc ContentTypeConfig
+				if err := mapstructure.WeakDecode(v, &ctc); err != nil {
+					return c, nil, err
+				}
+				s[k] = ctc
+			}
+		}
+
+		for k := range s {
+			mediaType, found := types.GetByType(k)
+			if !found {
+				return c, nil, fmt.Errorf("unknown media type %q", k)
+			}
+			c.types = append(c.types, mediaType)
+		}
+
+		c.init(types)
+
+		return c, s, nil
+	}
+
+	ns, err := config.DecodeNamespace[map[string]ContentTypeConfig](in, buildConfig)
+	if err != nil {
+		return nil, fmt.Errorf("failed to decode media types: %w", err)
+	}
+	return ns, nil
+}
+
 // DecodeTypes decodes the given map of media types.
 func DecodeTypes(in map[string]any) (*config.ConfigNamespace[map[string]MediaTypeConfig, Types], error) {
 	buildConfig := func(v any) (Types, any, error) {
@@ -220,6 +273,6 @@ func DecodeTypes(in map[string]any) (*config.ConfigNamespace[map[string]MediaTyp
 // TODO(bep) get rid of this.
 var DefaultPathParser = &paths.PathParser{
 	IsContentExt: func(ext string) bool {
-		return DefaultContentTypes.IsContentSuffix(ext)
+		panic("not supported")
 	},
 }
diff --git a/parser/lowercase_camel_json.go b/parser/lowercase_camel_json.go
index c61a4078ec5..468c1a8fee8 100644
--- a/parser/lowercase_camel_json.go
+++ b/parser/lowercase_camel_json.go
@@ -107,7 +107,7 @@ func (c ReplacingJSONMarshaller) MarshalJSON() ([]byte, error) {
 		var removeZeroVAlues func(m map[string]any)
 		removeZeroVAlues = func(m map[string]any) {
 			for k, v := range m {
-				if !hreflect.IsTruthful(v) {
+				if !hreflect.IsMap(v) && !hreflect.IsTruthful(v) {
 					delete(m, k)
 				} else {
 					switch vv := v.(type) {
diff --git a/resources/page/page_nop.go b/resources/page/page_nop.go
index af9f2682d37..398a7df02ce 100644
--- a/resources/page/page_nop.go
+++ b/resources/page/page_nop.go
@@ -21,7 +21,6 @@ import (
 	"html/template"
 	"time"
 
-	"github.com/gohugoio/hugo/hugofs/files"
 	"github.com/gohugoio/hugo/markup/converter"
 	"github.com/gohugoio/hugo/markup/tableofcontents"
 
@@ -59,8 +58,6 @@ var (
 // PageNop implements Page, but does nothing.
 type nopPage int
 
-var noOpPathInfo = media.DefaultPathParser.Parse(files.ComponentFolderContent, "no-op.md")
-
 func (p *nopPage) Aliases() []string {
 	return nil
 }
@@ -338,7 +335,7 @@ func (p *nopPage) Path() string {
 }
 
 func (p *nopPage) PathInfo() *paths.Path {
-	return noOpPathInfo
+	return nil
 }
 
 func (p *nopPage) Permalink() string {
diff --git a/source/fileInfo.go b/source/fileInfo.go
index 8994dec976c..8403c808894 100644
--- a/source/fileInfo.go
+++ b/source/fileInfo.go
@@ -132,6 +132,7 @@ func (fi *File) p() *paths.Path {
 	return fi.fim.Meta().PathInfo.Unnormalized()
 }
 
+// Used in tests.
 func NewFileInfoFrom(path, filename string) *File {
 	meta := &hugofs.FileMeta{
 		Filename: filename,
diff --git a/tpl/reflect/reflect.go b/tpl/reflect/reflect.go
index 07834be1c81..c19c8c178a8 100644
--- a/tpl/reflect/reflect.go
+++ b/tpl/reflect/reflect.go
@@ -14,7 +14,7 @@
 package reflect
 
 import (
-	"reflect"
+	"github.com/gohugoio/hugo/common/hreflect"
 )
 
 // New returns a new instance of the reflect-namespaced template functions.
@@ -27,10 +27,10 @@ type Namespace struct{}
 
 // IsMap reports whether v is a map.
 func (ns *Namespace) IsMap(v any) bool {
-	return reflect.ValueOf(v).Kind() == reflect.Map
+	return hreflect.IsMap(v)
 }
 
 // IsSlice reports whether v is a slice.
 func (ns *Namespace) IsSlice(v any) bool {
-	return reflect.ValueOf(v).Kind() == reflect.Slice
+	return hreflect.IsSlice(v)
 }


Test patch:
diff --git a/config/allconfig/allconfig_integration_test.go b/config/allconfig/allconfig_integration_test.go
index 162a36cdb89..cae04ba8546 100644
--- a/config/allconfig/allconfig_integration_test.go
+++ b/config/allconfig/allconfig_integration_test.go
@@ -7,6 +7,7 @@ import (
 	qt "github.com/frankban/quicktest"
 	"github.com/gohugoio/hugo/config/allconfig"
 	"github.com/gohugoio/hugo/hugolib"
+	"github.com/gohugoio/hugo/media"
 )
 
 func TestDirsMount(t *testing.T) {
@@ -97,7 +98,7 @@ suffixes = ["html", "xhtml"]
 	b := hugolib.Test(t, files)
 
 	conf := b.H.Configs.Base
-	contentTypes := conf.C.ContentTypes
+	contentTypes := conf.ContentTypes.Config
 
 	b.Assert(contentTypes.HTML.Suffixes(), qt.DeepEquals, []string{"html", "xhtml"})
 	b.Assert(contentTypes.Markdown.Suffixes(), qt.DeepEquals, []string{"md", "mdown", "markdown"})
@@ -215,3 +216,21 @@ weight = 3
 	b := hugolib.Test(t, files)
 	b.Assert(b.H.Configs.LanguageConfigSlice[0].Title, qt.Equals, `TITLE_DE`)
 }
+
+func TestContentTypesDefault(t *testing.T) {
+	files := `
+-- hugo.toml --
+baseURL = "https://example.com"
+
+
+`
+
+	b := hugolib.Test(t, files)
+
+	ct := b.H.Configs.Base.ContentTypes
+	c := ct.Config
+	s := ct.SourceStructure.(map[string]media.ContentTypeConfig)
+
+	b.Assert(c.IsContentFile("foo.md"), qt.Equals, true)
+	b.Assert(len(s), qt.Equals, 6)
+}
diff --git a/hugolib/content_map_test.go b/hugolib/content_map_test.go
index 6a12458462f..4cbdaf53a48 100644
--- a/hugolib/content_map_test.go
+++ b/hugolib/content_map_test.go
@@ -501,3 +501,51 @@ func (n *testContentNode) resetBuildState() {
 
 func (n *testContentNode) MarkStale() {
 }
+
+// Issue 12274.
+func TestHTMLNotContent(t *testing.T) {
+	filesTemplate := `
+-- hugo.toml.temp --
+[contentTypes]
+[contentTypes."text/markdown"]
+# Emopty for now.
+-- hugo.yaml.temp --
+contentTypes:
+  text/markdown: {}
+-- hugo.json.temp --
+{
+  "contentTypes": {
+    "text/markdown": {}
+  }
+}
+-- content/p1/index.md --
+---
+title: p1
+---
+-- content/p1/a.html --
+<p>a</p>
+-- content/p1/b.html --
+<p>b</p>
+-- content/p1/c.html --
+<p>c</p>
+-- layouts/_default/single.html --
+|{{ (.Resources.Get "a.html").RelPermalink -}}
+|{{ (.Resources.Get "b.html").RelPermalink -}}
+|{{ (.Resources.Get "c.html").Publish }}
+`
+
+	for _, format := range []string{"toml", "yaml", "json"} {
+		format := format
+		t.Run(format, func(t *testing.T) {
+			t.Parallel()
+
+			files := strings.Replace(filesTemplate, format+".temp", format, 1)
+			b := Test(t, files)
+
+			b.AssertFileContent("public/p1/index.html", "|/p1/a.html|/p1/b.html|")
+			b.AssertFileContent("public/p1/a.html", "<p>a</p>")
+			b.AssertFileContent("public/p1/b.html", "<p>b</p>")
+			b.AssertFileContent("public/p1/c.html", "<p>c</p>")
+		})
+	}
+}
diff --git a/media/mediaType_test.go b/media/mediaType_test.go
index fb3eb664f9e..3b8e099b897 100644
--- a/media/mediaType_test.go
+++ b/media/mediaType_test.go
@@ -214,11 +214,3 @@ func BenchmarkTypeOps(b *testing.B) {
 
 	}
 }
-
-func TestIsContentFile(t *testing.T) {
-	c := qt.New(t)
-
-	c.Assert(DefaultContentTypes.IsContentFile(filepath.FromSlash("my/file.md")), qt.Equals, true)
-	c.Assert(DefaultContentTypes.IsContentFile(filepath.FromSlash("my/file.ad")), qt.Equals, true)
-	c.Assert(DefaultContentTypes.IsContentFile(filepath.FromSlash("textfile.txt")), qt.Equals, false)
-}