diff --git a/hugolib/page_paths.go b/hugolib/page_paths.go index 8aa70b95b..73fd62278 100644 --- a/hugolib/page_paths.go +++ b/hugolib/page_paths.go @@ -164,7 +164,7 @@ func createTargetPath(d targetPathDescriptor) string { 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 @@ func createTargetPath(d targetPathDescriptor) string { } 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 @@ func createTargetPath(d targetPathDescriptor) string { 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) diff --git a/hugolib/page_paths_test.go b/hugolib/page_paths_test.go index 9a2db1192..80dc390cc 100644 --- a/hugolib/page_paths_test.go +++ b/hugolib/page_paths_test.go @@ -18,6 +18,8 @@ import ( "strings" "testing" + "github.com/gohugoio/hugo/media" + "fmt" "github.com/gohugoio/hugo/output" @@ -27,6 +29,17 @@ func TestPageTargetPath(t *testing.T) { 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 @@ func TestPageTargetPath(t *testing.T) { {"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"}, diff --git a/hugolib/site_output_test.go b/hugolib/site_output_test.go index 6aff84397..8455a13f7 100644 --- a/hugolib/site_output_test.go +++ b/hugolib/site_output_test.go @@ -290,3 +290,76 @@ baseName = "feed" 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()) + +} diff --git a/media/mediaType.go b/media/mediaType.go index 6b6f90439..2f238ba23 100644 --- a/media/mediaType.go +++ b/media/mediaType.go @@ -22,6 +22,10 @@ import ( "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 @@ import ( // 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 @@ func FromString(t string) (Type, error) { 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 @@ func (m Type) String() string { 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{ diff --git a/media/mediaType_test.go b/media/mediaType_test.go index 8d83c19f8..a6b18d1d6 100644 --- a/media/mediaType_test.go +++ b/media/mediaType_test.go @@ -40,6 +40,7 @@ func TestDefaultTypes(t *testing.T) { 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 @@ func TestFromTypeString(t *testing.T) { 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) diff --git a/output/layout.go b/output/layout.go index 6dba7f3b4..cacb92b80 100644 --- a/output/layout.go +++ b/output/layout.go @@ -181,17 +181,37 @@ func resolveListTemplate(d LayoutDescriptor, f Format, 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 { @@ -220,7 +240,12 @@ func regularPageLayouts(types string, layout string, f Format) []string { 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 @@ func regularPageLayouts(types string, layout string, f Format) []string { // 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) } diff --git a/output/layout_test.go b/output/layout_test.go index 56aac00d5..9d4d2f6d5 100644 --- a/output/layout_test.go +++ b/output/layout_test.go @@ -21,14 +21,34 @@ import ( "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 @@ func TestLayout(t *testing.T) { }{ {"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,