Add ContentTypes to config

This is an empty struct for now, but we will most likely expand on that.

```
[contentTypes]
  [contentTypes.'text/markdown']
```

The above means that only Markdown will be considered a content type. E.g. HTML will be treated as plain text.

Fixes #12274
This commit is contained in:
Bjørn Erik Pedersen 2025-02-07 10:29:35 +01:00
parent 4245a4514d
commit c2fb221209
12 changed files with 182 additions and 52 deletions

View file

@ -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.

View file

@ -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 {

View file

@ -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)
}

View file

@ -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 {

View file

@ -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.

View file

@ -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>")
})
}
}

View file

@ -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")
},
}

View file

@ -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)
}

View file

@ -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) {

View file

@ -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 {

View file

@ -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,

View file

@ -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)
}