shithub: hugo

Download patch

ref: 0f40e1fadfca2276f65adefa6d7d5d63aef9160a
parent: 516e6c6dc5733cdaf985317d58eedbc6ec0ef2f7
author: Bjørn Erik Pedersen <[email protected]>
date: Tue Jun 20 04:45:52 EDT 2017

media, hugolib: Support extension-less media types

This change is motivated by Netlify's `_redirects` files, which is currently not possible to generate with Hugo.

This commit adds a `Delimiter` field to media type, which defaults to ".", but can be blanked out.

Fixes #3614

--- a/hugolib/page_paths.go
+++ b/hugolib/page_paths.go
@@ -164,7 +164,7 @@
 		if d.URL != "" {
 			pagePath = filepath.Join(pagePath, d.URL)
 			if strings.HasSuffix(d.URL, "/") || !strings.Contains(d.URL, ".") {
-				pagePath = filepath.Join(pagePath, d.Type.BaseName+"."+d.Type.MediaType.Suffix)
+				pagePath = filepath.Join(pagePath, d.Type.BaseName+d.Type.MediaType.FullSuffix())
 			}
 		} else {
 			if d.ExpandedPermalink != "" {
@@ -184,9 +184,9 @@
 			}
 
 			if isUgly {
-				pagePath += "." + d.Type.MediaType.Suffix
+				pagePath += d.Type.MediaType.Delimiter + d.Type.MediaType.Suffix
 			} else {
-				pagePath = filepath.Join(pagePath, d.Type.BaseName+"."+d.Type.MediaType.Suffix)
+				pagePath = filepath.Join(pagePath, d.Type.BaseName+d.Type.MediaType.FullSuffix())
 			}
 
 			if d.LangPrefix != "" {
@@ -207,7 +207,7 @@
 			base = helpers.FilePathSeparator + d.Type.BaseName
 		}
 
-		pagePath += base + "." + d.Type.MediaType.Suffix
+		pagePath += base + d.Type.MediaType.FullSuffix()
 
 		if d.LangPrefix != "" {
 			pagePath = filepath.Join(d.LangPrefix, pagePath)
--- a/hugolib/page_paths_test.go
+++ b/hugolib/page_paths_test.go
@@ -18,6 +18,8 @@
 	"strings"
 	"testing"
 
+	"github.com/gohugoio/hugo/media"
+
 	"fmt"
 
 	"github.com/gohugoio/hugo/output"
@@ -27,6 +29,17 @@
 
 	pathSpec := newTestDefaultPathSpec()
 
+	noExtNoDelimMediaType := media.TextType
+	noExtNoDelimMediaType.Suffix = ""
+	noExtNoDelimMediaType.Delimiter = ""
+
+	// Netlify style _redirects
+	noExtDelimFormat := output.Format{
+		Name:      "NER",
+		MediaType: noExtNoDelimMediaType,
+		BaseName:  "_redirects",
+	}
+
 	for _, langPrefix := range []string{"", "no"} {
 		for _, uglyURLs := range []bool{false, true} {
 			t.Run(fmt.Sprintf("langPrefix=%q,uglyURLs=%t", langPrefix, uglyURLs),
@@ -40,6 +53,7 @@
 						{"JSON home", targetPathDescriptor{Kind: KindHome, Type: output.JSONFormat}, "/index.json"},
 						{"AMP home", targetPathDescriptor{Kind: KindHome, Type: output.AMPFormat}, "/amp/index.html"},
 						{"HTML home", targetPathDescriptor{Kind: KindHome, BaseName: "_index", Type: output.HTMLFormat}, "/index.html"},
+						{"Netlify redirects", targetPathDescriptor{Kind: KindHome, BaseName: "_index", Type: noExtDelimFormat}, "/_redirects"},
 						{"HTML section list", targetPathDescriptor{
 							Kind:     KindSection,
 							Sections: []string{"sect1"},
--- a/hugolib/site_output_test.go
+++ b/hugolib/site_output_test.go
@@ -290,3 +290,76 @@
 	require.Equal(t, "http://example.com/blog/feed.xml", s.Info.RSSLink)
 
 }
+
+// Issue #3614
+func TestDotLessOutputFormat(t *testing.T) {
+	siteConfig := `
+baseURL = "http://example.com/blog"
+
+paginate = 1
+defaultContentLanguage = "en"
+
+disableKinds = ["page", "section", "taxonomy", "taxonomyTerm", "sitemap", "robotsTXT", "404"]
+
+[mediaTypes]
+[mediaTypes."text/nodot"]
+suffix = ""
+delimiter = ""
+[mediaTypes."text/defaultdelim"]
+suffix = "defd"
+[mediaTypes."text/nosuffix"]
+suffix = ""
+[mediaTypes."text/customdelim"]
+suffix = "del"
+delimiter = "_"
+
+[outputs]
+home = [ "DOTLESS", "DEF", "NOS", "CUS" ]
+
+[outputFormats]
+[outputFormats.DOTLESS]
+mediatype = "text/nodot"
+baseName = "_redirects" # This is how Netlify names their redirect files.
+[outputFormats.DEF]
+mediatype = "text/defaultdelim"
+baseName = "defaultdelimbase"
+[outputFormats.NOS]
+mediatype = "text/nosuffix"
+baseName = "nosuffixbase"
+[outputFormats.CUS]
+mediatype = "text/customdelim"
+baseName = "customdelimbase"
+
+`
+
+	mf := afero.NewMemMapFs()
+	writeToFs(t, mf, "content/foo.html", `foo`)
+	writeToFs(t, mf, "layouts/_default/list.dotless", `a dotless`)
+	writeToFs(t, mf, "layouts/_default/list.def.defd", `default delimim`)
+	writeToFs(t, mf, "layouts/_default/list.nos", `no suffix`)
+	writeToFs(t, mf, "layouts/_default/list.cus.del", `custom delim`)
+
+	th, h := newTestSitesFromConfig(t, mf, siteConfig)
+
+	err := h.Build(BuildCfg{})
+
+	require.NoError(t, err)
+
+	th.assertFileContent("public/_redirects", "a dotless")
+	th.assertFileContent("public/defaultdelimbase.defd", "default delimim")
+	// This looks weird, but the user has chosen this definition.
+	th.assertFileContent("public/nosuffixbase.", "no suffix")
+	th.assertFileContent("public/customdelimbase_del", "custom delim")
+
+	s := h.Sites[0]
+	home := s.getPage(KindHome)
+	require.NotNil(t, home)
+
+	outputs := home.OutputFormats()
+
+	require.Equal(t, "/blog/_redirects", outputs.Get("DOTLESS").RelPermalink())
+	require.Equal(t, "/blog/defaultdelimbase.defd", outputs.Get("DEF").RelPermalink())
+	require.Equal(t, "/blog/nosuffixbase.", outputs.Get("NOS").RelPermalink())
+	require.Equal(t, "/blog/customdelimbase_del", outputs.Get("CUS").RelPermalink())
+
+}
--- a/media/mediaType.go
+++ b/media/mediaType.go
@@ -22,6 +22,10 @@
 	"github.com/mitchellh/mapstructure"
 )
 
+const (
+	defaultDelimiter = "."
+)
+
 // 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.
@@ -29,9 +33,10 @@
 // If suffix is not provided, the sub type will be used.
 // See // https://en.wikipedia.org/wiki/Media_type
 type Type struct {
-	MainType string // i.e. text
-	SubType  string // i.e. html
-	Suffix   string // i.e html
+	MainType  string // i.e. text
+	SubType   string // i.e. html
+	Suffix    string // i.e html
+	Delimiter string // defaults to "."
 }
 
 // FromTypeString creates a new Type given a type sring on the form MainType/SubType and
@@ -54,7 +59,7 @@
 		suffix = subParts[1]
 	}
 
-	return Type{MainType: mainType, SubType: subType, Suffix: suffix}, nil
+	return Type{MainType: mainType, SubType: subType, Suffix: suffix, Delimiter: defaultDelimiter}, nil
 }
 
 // Type returns a string representing the main- and sub-type of a media type, i.e. "text/css".
@@ -72,16 +77,21 @@
 	return fmt.Sprintf("%s/%s", m.MainType, m.SubType)
 }
 
+// FullSuffix returns the file suffix with any delimiter prepended.
+func (m Type) FullSuffix() string {
+	return m.Delimiter + m.Suffix
+}
+
 var (
-	CalendarType   = Type{"text", "calendar", "ics"}
-	CSSType        = Type{"text", "css", "css"}
-	CSVType        = Type{"text", "csv", "csv"}
-	HTMLType       = Type{"text", "html", "html"}
-	JavascriptType = Type{"application", "javascript", "js"}
-	JSONType       = Type{"application", "json", "json"}
-	RSSType        = Type{"application", "rss", "xml"}
-	XMLType        = Type{"application", "xml", "xml"}
-	TextType       = Type{"text", "plain", "txt"}
+	CalendarType   = Type{"text", "calendar", "ics", defaultDelimiter}
+	CSSType        = Type{"text", "css", "css", defaultDelimiter}
+	CSVType        = Type{"text", "csv", "csv", defaultDelimiter}
+	HTMLType       = Type{"text", "html", "html", defaultDelimiter}
+	JavascriptType = Type{"application", "javascript", "js", defaultDelimiter}
+	JSONType       = Type{"application", "json", "json", defaultDelimiter}
+	RSSType        = Type{"application", "rss", "xml", defaultDelimiter}
+	XMLType        = Type{"application", "xml", "xml", defaultDelimiter}
+	TextType       = Type{"text", "plain", "txt", defaultDelimiter}
 )
 
 var DefaultTypes = Types{
--- a/media/mediaType_test.go
+++ b/media/mediaType_test.go
@@ -40,6 +40,7 @@
 		require.Equal(t, test.expectedMainType, test.tp.MainType)
 		require.Equal(t, test.expectedSubType, test.tp.SubType)
 		require.Equal(t, test.expectedSuffix, test.tp.Suffix)
+		require.Equal(t, defaultDelimiter, test.tp.Delimiter)
 
 		require.Equal(t, test.expectedType, test.tp.Type())
 		require.Equal(t, test.expectedString, test.tp.String())
@@ -66,11 +67,11 @@
 
 	f, err = FromString("application/custom")
 	require.NoError(t, err)
-	require.Equal(t, Type{MainType: "application", SubType: "custom", Suffix: "custom"}, f)
+	require.Equal(t, Type{MainType: "application", SubType: "custom", Suffix: "custom", Delimiter: defaultDelimiter}, f)
 
 	f, err = FromString("application/custom+pdf")
 	require.NoError(t, err)
-	require.Equal(t, Type{MainType: "application", SubType: "custom", Suffix: "pdf"}, f)
+	require.Equal(t, Type{MainType: "application", SubType: "custom", Suffix: "pdf", Delimiter: defaultDelimiter}, f)
 
 	f, err = FromString("noslash")
 	require.Error(t, err)
--- a/output/layout.go
+++ b/output/layout.go
@@ -181,19 +181,39 @@
 	case "taxonomyTerm":
 		layouts = resolveTemplate(taxonomyTermLayouts, d, f)
 	}
-
 	return layouts
 }
 
 func resolveTemplate(templ string, d LayoutDescriptor, f Format) []string {
+	delim := "."
+	if f.MediaType.Delimiter == "" {
+		delim = ""
+	}
 	layouts := strings.Fields(replaceKeyValues(templ,
-		"SUFFIX", f.MediaType.Suffix,
+		".SUFFIX", delim+f.MediaType.Suffix,
 		"NAME", strings.ToLower(f.Name),
 		"SECTION", d.Section))
 
-	return layouts
+	return filterDotLess(layouts)
 }
 
+func filterDotLess(layouts []string) []string {
+	var filteredLayouts []string
+
+	for _, l := range layouts {
+		// This may be constructed, but media types can be suffix-less, but can contain
+		// a delimiter.
+		l = strings.TrimSuffix(l, ".")
+		// If media type has no suffix, we have "index" type of layouts in this list, which
+		// doesn't make much sense.
+		if strings.Contains(l, ".") {
+			filteredLayouts = append(filteredLayouts, l)
+		}
+	}
+
+	return filteredLayouts
+}
+
 func prependTextPrefixIfNeeded(f Format, layouts ...string) []string {
 	if !f.IsPlainText {
 		return layouts
@@ -220,7 +240,12 @@
 		layout = "single"
 	}
 
-	suffix := f.MediaType.Suffix
+	delimiter := "."
+	if f.MediaType.Delimiter == "" {
+		delimiter = ""
+	}
+
+	suffix := delimiter + f.MediaType.Suffix
 	name := strings.ToLower(f.Name)
 
 	if types != "" {
@@ -229,15 +254,15 @@
 		// Add type/layout.html
 		for i := range t {
 			search := t[:len(t)-i]
-			layouts = append(layouts, fmt.Sprintf("%s/%s.%s.%s", strings.ToLower(path.Join(search...)), layout, name, suffix))
-			layouts = append(layouts, fmt.Sprintf("%s/%s.%s", strings.ToLower(path.Join(search...)), layout, suffix))
+			layouts = append(layouts, fmt.Sprintf("%s/%s.%s%s", strings.ToLower(path.Join(search...)), layout, name, suffix))
+			layouts = append(layouts, fmt.Sprintf("%s/%s%s", strings.ToLower(path.Join(search...)), layout, suffix))
 
 		}
 	}
 
 	// Add _default/layout.html
-	layouts = append(layouts, fmt.Sprintf("_default/%s.%s.%s", layout, name, suffix))
-	layouts = append(layouts, fmt.Sprintf("_default/%s.%s", layout, suffix))
+	layouts = append(layouts, fmt.Sprintf("_default/%s.%s%s", layout, name, suffix))
+	layouts = append(layouts, fmt.Sprintf("_default/%s%s", layout, suffix))
 
-	return layouts
+	return filterDotLess(layouts)
 }
--- a/output/layout_test.go
+++ b/output/layout_test.go
@@ -21,14 +21,34 @@
 	"github.com/stretchr/testify/require"
 )
 
-var ampType = Format{
-	Name:      "AMP",
-	MediaType: media.HTMLType,
-	BaseName:  "index",
-}
-
 func TestLayout(t *testing.T) {
 
+	noExtNoDelimMediaType := media.TextType
+	noExtNoDelimMediaType.Suffix = ""
+	noExtNoDelimMediaType.Delimiter = ""
+
+	noExtMediaType := media.TextType
+	noExtMediaType.Suffix = ""
+
+	var (
+		ampType = Format{
+			Name:      "AMP",
+			MediaType: media.HTMLType,
+			BaseName:  "index",
+		}
+
+		noExtDelimFormat = Format{
+			Name:      "NEM",
+			MediaType: noExtNoDelimMediaType,
+			BaseName:  "_redirects",
+		}
+		noExt = Format{
+			Name:      "NEX",
+			MediaType: noExtMediaType,
+			BaseName:  "next",
+		}
+	)
+
 	for _, this := range []struct {
 		name           string
 		d              LayoutDescriptor
@@ -39,6 +59,12 @@
 	}{
 		{"Home", LayoutDescriptor{Kind: "home"}, true, "", ampType,
 			[]string{"index.amp.html", "index.html", "_default/list.amp.html", "_default/list.html", "theme/index.amp.html", "theme/index.html"}},
+		{"Home, no ext or delim", LayoutDescriptor{Kind: "home"}, true, "", noExtDelimFormat,
+			[]string{"index.nem", "_default/list.nem"}},
+		{"Home, no ext", LayoutDescriptor{Kind: "home"}, true, "", noExt,
+			[]string{"index.nex", "_default/list.nex"}},
+		{"Page, no ext or delim", LayoutDescriptor{Kind: "page"}, true, "", noExtDelimFormat,
+			[]string{"_default/single.nem", "theme/_default/single.nem"}},
 		{"Section", LayoutDescriptor{Kind: "section", Section: "sect1"}, false, "", ampType,
 			[]string{"section/sect1.amp.html", "section/sect1.html"}},
 		{"Taxonomy", LayoutDescriptor{Kind: "taxonomy", Section: "tag"}, false, "", ampType,