diff --git a/hugolib/page.go b/hugolib/page.go index 5a04c6ce7..9aa75a882 100644 --- a/hugolib/page.go +++ b/hugolib/page.go @@ -909,7 +909,7 @@ func (p *Page) update(f interface{}) error { o := cast.ToStringSlice(v) if len(o) > 0 { // Output formats are exlicitly set in front matter, use those. - outFormats, err := output.GetFormats(o...) + outFormats, err := output.DefaultFormats.GetByNames(o...) if err != nil { p.s.Log.ERROR.Printf("Failed to resolve output formats: %s", err) diff --git a/hugolib/site_output.go b/hugolib/site_output.go index 69e68ff9b..5ac5fe1f7 100644 --- a/hugolib/site_output.go +++ b/hugolib/site_output.go @@ -40,7 +40,7 @@ func createSiteOutputFormats(cfg config.Provider) (map[string]output.Formats, er var formats output.Formats vals := cast.ToStringSlice(v) for _, format := range vals { - f, found := output.GetFormat(format) + f, found := output.DefaultFormats.GetByName(format) if !found { return nil, fmt.Errorf("Failed to resolve output format %q from site config", format) } diff --git a/media/mediaType.go b/media/mediaType.go index a6ba873eb..b56904cd9 100644 --- a/media/mediaType.go +++ b/media/mediaType.go @@ -15,10 +15,20 @@ package media import ( "fmt" + "strings" ) type Types []Type +func (t Types) GetByType(tp string) (Type, bool) { + for _, tt := range t { + if strings.EqualFold(tt.Type(), tp) { + return tt, true + } + } + return Type{}, false +} + // A media type (also known as MIME type and content type) is a two-part identifier for // file formats and format contents transmitted on the Internet. // For Hugo's use case, we use the top-level type name / subtype name + suffix. diff --git a/media/mediaType_test.go b/media/mediaType_test.go index e918b9393..c97ac782a 100644 --- a/media/mediaType_test.go +++ b/media/mediaType_test.go @@ -47,3 +47,14 @@ func TestDefaultTypes(t *testing.T) { } } + +func TestGetByType(t *testing.T) { + types := Types{HTMLType, RSSType} + + mt, found := types.GetByType("text/HTML") + require.True(t, found) + require.Equal(t, mt, HTMLType) + + _, found = types.GetByType("text/nono") + require.False(t, found) +} diff --git a/output/outputFormat.go b/output/outputFormat.go index f2bd941a6..99420f720 100644 --- a/output/outputFormat.go +++ b/output/outputFormat.go @@ -15,11 +15,55 @@ package output import ( "fmt" + "sort" "strings" + "reflect" + + "github.com/mitchellh/mapstructure" + "github.com/spf13/hugo/media" ) +// Format represents an output representation, usually to a file on disk. +type Format struct { + // The Name is used as an identifier. Internal output formats (i.e. HTML and RSS) + // can be overridden by providing a new definition for those types. + Name string + + MediaType media.Type + + // Must be set to a value when there are two or more conflicting mediatype for the same resource. + Path string + + // The base output file name used when not using "ugly URLs", defaults to "index". + BaseName string + + // The value to use for rel links + // + // See https://www.w3schools.com/tags/att_link_rel.asp + // + // AMP has a special requirement in this department, see: + // https://www.ampproject.org/docs/guides/deploy/discovery + // I.e.: + // + Rel string + + // The protocol to use, i.e. "webcal://". Defaults to the protocol of the baseURL. + Protocol string + + // IsPlainText decides whether to use text/template or html/template + // as template parser. + IsPlainText bool + + // IsHTML returns whether this format is int the HTML family. This includes + // HTML, AMP etc. This is used to decide when to create alias redirects etc. + IsHTML bool + + // Enable to ignore the global uglyURLs setting. + NoUgly bool +} + var ( // An ordered list of built-in output formats // @@ -33,7 +77,6 @@ var ( IsHTML: true, } - // CalendarFormat is AAA CalendarFormat = Format{ Name: "Calendar", MediaType: media.CalendarType, @@ -83,32 +126,33 @@ var ( } ) -var builtInTypes = map[string]Format{ - strings.ToLower(AMPFormat.Name): AMPFormat, - strings.ToLower(CalendarFormat.Name): CalendarFormat, - strings.ToLower(CSSFormat.Name): CSSFormat, - strings.ToLower(CSVFormat.Name): CSVFormat, - strings.ToLower(HTMLFormat.Name): HTMLFormat, - strings.ToLower(JSONFormat.Name): JSONFormat, - strings.ToLower(RSSFormat.Name): RSSFormat, +var DefaultFormats = Formats{ + AMPFormat, + CalendarFormat, + CSSFormat, + CSVFormat, + HTMLFormat, + JSONFormat, + RSSFormat, +} + +func init() { + sort.Sort(DefaultFormats) } type Formats []Format -func (formats Formats) GetByName(name string) (f Format, found bool) { - for _, ff := range formats { - if name == ff.Name { - f = ff - found = true - return - } - } - return -} +func (f Formats) Len() int { return len(f) } +func (f Formats) Swap(i, j int) { f[i], f[j] = f[j], f[i] } +func (f Formats) Less(i, j int) bool { return f[i].Name < f[j].Name } -func (formats Formats) GetBySuffix(name string) (f Format, found bool) { +// GetBySuffix gets a output format given as suffix, e.g. "html". +// It will return false if no format could be found, or if the suffix given +// is ambiguous. +// The lookup is case insensitive. +func (formats Formats) GetBySuffix(suffix string) (f Format, found bool) { for _, ff := range formats { - if name == ff.MediaType.Suffix { + if strings.EqualFold(suffix, ff.MediaType.Suffix) { if found { // ambiguous found = false @@ -121,6 +165,33 @@ func (formats Formats) GetBySuffix(name string) (f Format, found bool) { return } +// GetByName gets a format by its identifier name. +func (formats Formats) GetByName(name string) (f Format, found bool) { + for _, ff := range formats { + if strings.EqualFold(name, ff.Name) { + f = ff + found = true + return + } + } + return +} + +// GetByNames gets a list of formats given a list of identifiers. +func (formats Formats) GetByNames(names ...string) (Formats, error) { + var types []Format + + for _, name := range names { + tpe, ok := formats.GetByName(name) + if !ok { + return types, fmt.Errorf("OutputFormat with key %q not found", name) + } + types = append(types, tpe) + } + return types, nil +} + +// FromFilename gets a Format given a filename. func (formats Formats) FromFilename(filename string) (f Format, found bool) { // mytemplate.amp.html // mytemplate.html @@ -145,66 +216,79 @@ func (formats Formats) FromFilename(filename string) (f Format, found bool) { return } -// Format represents an output representation, usually to a file on disk. -type Format struct { - // The Name is used as an identifier. Internal output formats (i.e. HTML and RSS) - // can be overridden by providing a new definition for those types. - Name string +// DecodeOutputFormats takes a list of output format configurations and merges those, +// in ther order given, with the Hugo defaults as the last resort. +func DecodeOutputFormats(mediaTypes media.Types, maps ...map[string]interface{}) (Formats, error) { + f := make(Formats, len(DefaultFormats)) + copy(f, DefaultFormats) - MediaType media.Type + for _, m := range maps { + for k, v := range m { + found := false + for i, vv := range f { + if strings.EqualFold(k, vv.Name) { + // Merge it with the existing + if err := decode(mediaTypes, v, &f[i]); err != nil { + return f, err + } + found = true + } + } + if !found { + var newOutFormat Format + newOutFormat.Name = k + if err := decode(mediaTypes, v, &newOutFormat); err != nil { + return f, err + } - // Must be set to a value when there are two or more conflicting mediatype for the same resource. - Path string - - // The base output file name used when not using "ugly URLs", defaults to "index". - BaseName string - - // The value to use for rel links - // - // See https://www.w3schools.com/tags/att_link_rel.asp - // - // AMP has a special requirement in this department, see: - // https://www.ampproject.org/docs/guides/deploy/discovery - // I.e.: - // - Rel string - - // The protocol to use, i.e. "webcal://". Defaults to the protocol of the baseURL. - Protocol string - - // IsPlainText decides whether to use text/template or html/template - // as template parser. - IsPlainText bool - - // IsHTML returns whether this format is int the HTML family. This includes - // HTML, AMP etc. This is used to decide when to create alias redirects etc. - IsHTML bool - - // Enable to ignore the global uglyURLs setting. - NoUgly bool -} - -func GetFormat(key string) (Format, bool) { - found, ok := builtInTypes[key] - if !ok { - found, ok = builtInTypes[strings.ToLower(key)] - } - return found, ok -} - -// TODO(bep) outputs rewamp on global config? -func GetFormats(keys ...string) (Formats, error) { - var types []Format - - for _, key := range keys { - tpe, ok := GetFormat(key) - if !ok { - return types, fmt.Errorf("OutputFormat with key %q not found", key) + f = append(f, newOutFormat) + } } - types = append(types, tpe) } - return types, nil + sort.Sort(f) + + return f, nil +} + +func decode(mediaTypes media.Types, input, output interface{}) error { + config := &mapstructure.DecoderConfig{ + Metadata: nil, + Result: output, + WeaklyTypedInput: true, + DecodeHook: func(a reflect.Type, b reflect.Type, c interface{}) (interface{}, error) { + if a.Kind() == reflect.Map { + dataVal := reflect.Indirect(reflect.ValueOf(c)) + for _, key := range dataVal.MapKeys() { + keyStr, ok := key.Interface().(string) + if !ok { + // Not a string key + continue + } + if strings.EqualFold(keyStr, "mediaType") { + // If mediaType is a string, look it up and replace it + // in the map. + vv := dataVal.MapIndex(key) + if mediaTypeStr, ok := vv.Interface().(string); ok { + mediaType, found := mediaTypes.GetByType(mediaTypeStr) + if !found { + return c, fmt.Errorf("media type %q not found", mediaTypeStr) + } + dataVal.SetMapIndex(key, reflect.ValueOf(mediaType)) + } + } + } + } + return c, nil + }, + } + + decoder, err := mapstructure.NewDecoder(config) + if err != nil { + return err + } + + return decoder.Decode(input) } func (t Format) BaseFilename() string { diff --git a/output/outputFormat_test.go b/output/outputFormat_test.go index b73e53f82..48937a8f1 100644 --- a/output/outputFormat_test.go +++ b/output/outputFormat_test.go @@ -14,6 +14,7 @@ package output import ( + "fmt" "testing" "github.com/spf13/hugo/media" @@ -65,18 +66,9 @@ func TestDefaultTypes(t *testing.T) { } -func TestGetFormat(t *testing.T) { - tp, _ := GetFormat("html") - require.Equal(t, HTMLFormat, tp) - tp, _ = GetFormat("HTML") - require.Equal(t, HTMLFormat, tp) - _, found := GetFormat("FOO") - require.False(t, found) -} - -func TestGeGetFormatByName(t *testing.T) { +func TestGetFormatByName(t *testing.T) { formats := Formats{AMPFormat, CalendarFormat} - tp, _ := formats.GetByName("AMP") + tp, _ := formats.GetByName("AMp") require.Equal(t, AMPFormat, tp) _, found := formats.GetByName("HTML") require.False(t, found) @@ -84,7 +76,7 @@ func TestGeGetFormatByName(t *testing.T) { require.False(t, found) } -func TestGeGetFormatByExt(t *testing.T) { +func TestGetFormatByExt(t *testing.T) { formats1 := Formats{AMPFormat, CalendarFormat} formats2 := Formats{AMPFormat, HTMLFormat, CalendarFormat} tp, _ := formats1.GetBySuffix("html") @@ -95,6 +87,99 @@ func TestGeGetFormatByExt(t *testing.T) { require.False(t, found) // ambiguous - _, found = formats2.GetByName("html") + _, found = formats2.GetBySuffix("html") require.False(t, found) } + +func TestDecodeFormats(t *testing.T) { + + mediaTypes := media.Types{media.JSONType, media.XMLType} + + var tests = []struct { + name string + maps []map[string]interface{} + shouldError bool + assert func(t *testing.T, name string, f Formats) + }{ + { + "Redefine JSON", + []map[string]interface{}{ + map[string]interface{}{ + "JsON": map[string]interface{}{ + "baseName": "myindex", + "isPlainText": "false"}}}, + false, + func(t *testing.T, name string, f Formats) { + require.Len(t, f, len(DefaultFormats), name) + json, _ := f.GetByName("JSON") + require.Equal(t, "myindex", json.BaseName) + require.Equal(t, media.JSONType, json.MediaType) + require.False(t, json.IsPlainText) + + }}, + { + "Add XML format with string as mediatype", + []map[string]interface{}{ + map[string]interface{}{ + "MYXMLFORMAT": map[string]interface{}{ + "baseName": "myxml", + "mediaType": "application/xml", + }}}, + false, + func(t *testing.T, name string, f Formats) { + require.Len(t, f, len(DefaultFormats)+1, name) + xml, found := f.GetByName("MYXMLFORMAT") + require.True(t, found) + require.Equal(t, "myxml", xml.BaseName, fmt.Sprint(xml)) + require.Equal(t, media.XMLType, xml.MediaType) + + // Verify that we haven't changed the DefaultFormats slice. + json, _ := f.GetByName("JSON") + require.Equal(t, "index", json.BaseName, name) + + }}, + { + "Add format unknown mediatype", + []map[string]interface{}{ + map[string]interface{}{ + "MYINVALID": map[string]interface{}{ + "baseName": "mymy", + "mediaType": "application/hugo", + }}}, + true, + func(t *testing.T, name string, f Formats) { + + }}, + { + "Add and redefine XML format", + []map[string]interface{}{ + map[string]interface{}{ + "MYOTHERXMLFORMAT": map[string]interface{}{ + "baseName": "myotherxml", + "mediaType": media.XMLType, + }}, + map[string]interface{}{ + "MYOTHERXMLFORMAT": map[string]interface{}{ + "baseName": "myredefined", + }}, + }, + false, + func(t *testing.T, name string, f Formats) { + require.Len(t, f, len(DefaultFormats)+1, name) + xml, found := f.GetByName("MYOTHERXMLFORMAT") + require.True(t, found) + require.Equal(t, "myredefined", xml.BaseName, fmt.Sprint(xml)) + require.Equal(t, media.XMLType, xml.MediaType) + }}, + } + + for _, test := range tests { + result, err := DecodeOutputFormats(mediaTypes, test.maps...) + if test.shouldError { + require.Error(t, err, test.name) + } else { + require.NoError(t, err, test.name) + test.assert(t, test.name, result) + } + } +}