Skip to content

Commit 0f40e1f

Browse files
committed
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
1 parent 516e6c6 commit 0f40e1f

File tree

7 files changed

+183
-34
lines changed

7 files changed

+183
-34
lines changed

‎hugolib/page_paths.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ func createTargetPath(d targetPathDescriptor) string {
164164
if d.URL != "" {
165165
pagePath = filepath.Join(pagePath, d.URL)
166166
if strings.HasSuffix(d.URL, "/") || !strings.Contains(d.URL, ".") {
167-
pagePath = filepath.Join(pagePath, d.Type.BaseName+"."+d.Type.MediaType.Suffix)
167+
pagePath = filepath.Join(pagePath, d.Type.BaseName+d.Type.MediaType.FullSuffix())
168168
}
169169
} else {
170170
if d.ExpandedPermalink != "" {
@@ -184,9 +184,9 @@ func createTargetPath(d targetPathDescriptor) string {
184184
}
185185

186186
if isUgly {
187-
pagePath += "." + d.Type.MediaType.Suffix
187+
pagePath += d.Type.MediaType.Delimiter + d.Type.MediaType.Suffix
188188
} else {
189-
pagePath = filepath.Join(pagePath, d.Type.BaseName+"."+d.Type.MediaType.Suffix)
189+
pagePath = filepath.Join(pagePath, d.Type.BaseName+d.Type.MediaType.FullSuffix())
190190
}
191191

192192
if d.LangPrefix != "" {
@@ -207,7 +207,7 @@ func createTargetPath(d targetPathDescriptor) string {
207207
base = helpers.FilePathSeparator + d.Type.BaseName
208208
}
209209

210-
pagePath += base + "." + d.Type.MediaType.Suffix
210+
pagePath += base + d.Type.MediaType.FullSuffix()
211211

212212
if d.LangPrefix != "" {
213213
pagePath = filepath.Join(d.LangPrefix, pagePath)

‎hugolib/page_paths_test.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import (
1818
"strings"
1919
"testing"
2020

21+
"github.com/gohugoio/hugo/media"
22+
2123
"fmt"
2224

2325
"github.com/gohugoio/hugo/output"
@@ -27,6 +29,17 @@ func TestPageTargetPath(t *testing.T) {
2729

2830
pathSpec := newTestDefaultPathSpec()
2931

32+
noExtNoDelimMediaType := media.TextType
33+
noExtNoDelimMediaType.Suffix = ""
34+
noExtNoDelimMediaType.Delimiter = ""
35+
36+
// Netlify style _redirects
37+
noExtDelimFormat := output.Format{
38+
Name: "NER",
39+
MediaType: noExtNoDelimMediaType,
40+
BaseName: "_redirects",
41+
}
42+
3043
for _, langPrefix := range []string{"", "no"} {
3144
for _, uglyURLs := range []bool{false, true} {
3245
t.Run(fmt.Sprintf("langPrefix=%q,uglyURLs=%t", langPrefix, uglyURLs),
@@ -40,6 +53,7 @@ func TestPageTargetPath(t *testing.T) {
4053
{"JSON home", targetPathDescriptor{Kind: KindHome, Type: output.JSONFormat}, "/index.json"},
4154
{"AMP home", targetPathDescriptor{Kind: KindHome, Type: output.AMPFormat}, "/amp/index.html"},
4255
{"HTML home", targetPathDescriptor{Kind: KindHome, BaseName: "_index", Type: output.HTMLFormat}, "/index.html"},
56+
{"Netlify redirects", targetPathDescriptor{Kind: KindHome, BaseName: "_index", Type: noExtDelimFormat}, "/_redirects"},
4357
{"HTML section list", targetPathDescriptor{
4458
Kind: KindSection,
4559
Sections: []string{"sect1"},

‎hugolib/site_output_test.go‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,3 +290,76 @@ baseName = "feed"
290290
require.Equal(t, "http://example.com/blog/feed.xml", s.Info.RSSLink)
291291

292292
}
293+
294+
// Issue #3614
295+
func TestDotLessOutputFormat(t *testing.T) {
296+
siteConfig := `
297+
baseURL = "http://example.com/blog"
298+
299+
paginate = 1
300+
defaultContentLanguage = "en"
301+
302+
disableKinds = ["page", "section", "taxonomy", "taxonomyTerm", "sitemap", "robotsTXT", "404"]
303+
304+
[mediaTypes]
305+
[mediaTypes."text/nodot"]
306+
suffix = ""
307+
delimiter = ""
308+
[mediaTypes."text/defaultdelim"]
309+
suffix = "defd"
310+
[mediaTypes."text/nosuffix"]
311+
suffix = ""
312+
[mediaTypes."text/customdelim"]
313+
suffix = "del"
314+
delimiter = "_"
315+
316+
[outputs]
317+
home = [ "DOTLESS", "DEF", "NOS", "CUS" ]
318+
319+
[outputFormats]
320+
[outputFormats.DOTLESS]
321+
mediatype = "text/nodot"
322+
baseName = "_redirects" # This is how Netlify names their redirect files.
323+
[outputFormats.DEF]
324+
mediatype = "text/defaultdelim"
325+
baseName = "defaultdelimbase"
326+
[outputFormats.NOS]
327+
mediatype = "text/nosuffix"
328+
baseName = "nosuffixbase"
329+
[outputFormats.CUS]
330+
mediatype = "text/customdelim"
331+
baseName = "customdelimbase"
332+
333+
`
334+
335+
mf := afero.NewMemMapFs()
336+
writeToFs(t, mf, "content/foo.html", `foo`)
337+
writeToFs(t, mf, "layouts/_default/list.dotless", `a dotless`)
338+
writeToFs(t, mf, "layouts/_default/list.def.defd", `default delimim`)
339+
writeToFs(t, mf, "layouts/_default/list.nos", `no suffix`)
340+
writeToFs(t, mf, "layouts/_default/list.cus.del", `custom delim`)
341+
342+
th, h := newTestSitesFromConfig(t, mf, siteConfig)
343+
344+
err := h.Build(BuildCfg{})
345+
346+
require.NoError(t, err)
347+
348+
th.assertFileContent("public/_redirects", "a dotless")
349+
th.assertFileContent("public/defaultdelimbase.defd", "default delimim")
350+
// This looks weird, but the user has chosen this definition.
351+
th.assertFileContent("public/nosuffixbase.", "no suffix")
352+
th.assertFileContent("public/customdelimbase_del", "custom delim")
353+
354+
s := h.Sites[0]
355+
home := s.getPage(KindHome)
356+
require.NotNil(t, home)
357+
358+
outputs := home.OutputFormats()
359+
360+
require.Equal(t, "/blog/_redirects", outputs.Get("DOTLESS").RelPermalink())
361+
require.Equal(t, "/blog/defaultdelimbase.defd", outputs.Get("DEF").RelPermalink())
362+
require.Equal(t, "/blog/nosuffixbase.", outputs.Get("NOS").RelPermalink())
363+
require.Equal(t, "/blog/customdelimbase_del", outputs.Get("CUS").RelPermalink())
364+
365+
}

‎media/mediaType.go‎

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,21 @@ import (
2222
"github.com/mitchellh/mapstructure"
2323
)
2424

25+
const (
26+
defaultDelimiter = "."
27+
)
28+
2529
// A media type (also known as MIME type and content type) is a two-part identifier for
2630
// file formats and format contents transmitted on the Internet.
2731
// For Hugo's use case, we use the top-level type name / subtype name + suffix.
2832
// One example would be image/jpeg+jpg
2933
// If suffix is not provided, the sub type will be used.
3034
// See // https://en.wikipedia.org/wiki/Media_type
3135
type Type struct {
32-
MainType string // i.e. text
33-
SubType string // i.e. html
34-
Suffix string // i.e html
36+
MainType string // i.e. text
37+
SubType string // i.e. html
38+
Suffix string // i.e html
39+
Delimiter string // defaults to "."
3540
}
3641

3742
// 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) {
5459
suffix = subParts[1]
5560
}
5661

57-
return Type{MainType: mainType, SubType: subType, Suffix: suffix}, nil
62+
return Type{MainType: mainType, SubType: subType, Suffix: suffix, Delimiter: defaultDelimiter}, nil
5863
}
5964

6065
// 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 {
7277
return fmt.Sprintf("%s/%s", m.MainType, m.SubType)
7378
}
7479

80+
// FullSuffix returns the file suffix with any delimiter prepended.
81+
func (m Type) FullSuffix() string {
82+
return m.Delimiter + m.Suffix
83+
}
84+
7585
var (
76-
CalendarType = Type{"text", "calendar", "ics"}
77-
CSSType = Type{"text", "css", "css"}
78-
CSVType = Type{"text", "csv", "csv"}
79-
HTMLType = Type{"text", "html", "html"}
80-
JavascriptType = Type{"application", "javascript", "js"}
81-
JSONType = Type{"application", "json", "json"}
82-
RSSType = Type{"application", "rss", "xml"}
83-
XMLType = Type{"application", "xml", "xml"}
84-
TextType = Type{"text", "plain", "txt"}
86+
CalendarType = Type{"text", "calendar", "ics", defaultDelimiter}
87+
CSSType = Type{"text", "css", "css", defaultDelimiter}
88+
CSVType = Type{"text", "csv", "csv", defaultDelimiter}
89+
HTMLType = Type{"text", "html", "html", defaultDelimiter}
90+
JavascriptType = Type{"application", "javascript", "js", defaultDelimiter}
91+
JSONType = Type{"application", "json", "json", defaultDelimiter}
92+
RSSType = Type{"application", "rss", "xml", defaultDelimiter}
93+
XMLType = Type{"application", "xml", "xml", defaultDelimiter}
94+
TextType = Type{"text", "plain", "txt", defaultDelimiter}
8595
)
8696

8797
var DefaultTypes = Types{

‎media/mediaType_test.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func TestDefaultTypes(t *testing.T) {
4040
require.Equal(t, test.expectedMainType, test.tp.MainType)
4141
require.Equal(t, test.expectedSubType, test.tp.SubType)
4242
require.Equal(t, test.expectedSuffix, test.tp.Suffix)
43+
require.Equal(t, defaultDelimiter, test.tp.Delimiter)
4344

4445
require.Equal(t, test.expectedType, test.tp.Type())
4546
require.Equal(t, test.expectedString, test.tp.String())
@@ -66,11 +67,11 @@ func TestFromTypeString(t *testing.T) {
6667

6768
f, err = FromString("application/custom")
6869
require.NoError(t, err)
69-
require.Equal(t, Type{MainType: "application", SubType: "custom", Suffix: "custom"}, f)
70+
require.Equal(t, Type{MainType: "application", SubType: "custom", Suffix: "custom", Delimiter: defaultDelimiter}, f)
7071

7172
f, err = FromString("application/custom+pdf")
7273
require.NoError(t, err)
73-
require.Equal(t, Type{MainType: "application", SubType: "custom", Suffix: "pdf"}, f)
74+
require.Equal(t, Type{MainType: "application", SubType: "custom", Suffix: "pdf", Delimiter: defaultDelimiter}, f)
7475

7576
f, err = FromString("noslash")
7677
require.Error(t, err)

‎output/layout.go‎

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -181,17 +181,37 @@ func resolveListTemplate(d LayoutDescriptor, f Format,
181181
case "taxonomyTerm":
182182
layouts = resolveTemplate(taxonomyTermLayouts, d, f)
183183
}
184-
185184
return layouts
186185
}
187186

188187
func resolveTemplate(templ string, d LayoutDescriptor, f Format) []string {
188+
delim := "."
189+
if f.MediaType.Delimiter == "" {
190+
delim = ""
191+
}
189192
layouts := strings.Fields(replaceKeyValues(templ,
190-
"SUFFIX", f.MediaType.Suffix,
193+
".SUFFIX", delim+f.MediaType.Suffix,
191194
"NAME", strings.ToLower(f.Name),
192195
"SECTION", d.Section))
193196

194-
return layouts
197+
return filterDotLess(layouts)
198+
}
199+
200+
func filterDotLess(layouts []string) []string {
201+
var filteredLayouts []string
202+
203+
for _, l := range layouts {
204+
// This may be constructed, but media types can be suffix-less, but can contain
205+
// a delimiter.
206+
l = strings.TrimSuffix(l, ".")
207+
// If media type has no suffix, we have "index" type of layouts in this list, which
208+
// doesn't make much sense.
209+
if strings.Contains(l, ".") {
210+
filteredLayouts = append(filteredLayouts, l)
211+
}
212+
}
213+
214+
return filteredLayouts
195215
}
196216

197217
func prependTextPrefixIfNeeded(f Format, layouts ...string) []string {
@@ -220,7 +240,12 @@ func regularPageLayouts(types string, layout string, f Format) []string {
220240
layout = "single"
221241
}
222242

223-
suffix := f.MediaType.Suffix
243+
delimiter := "."
244+
if f.MediaType.Delimiter == "" {
245+
delimiter = ""
246+
}
247+
248+
suffix := delimiter + f.MediaType.Suffix
224249
name := strings.ToLower(f.Name)
225250

226251
if types != "" {
@@ -229,15 +254,15 @@ func regularPageLayouts(types string, layout string, f Format) []string {
229254
// Add type/layout.html
230255
for i := range t {
231256
search := t[:len(t)-i]
232-
layouts = append(layouts, fmt.Sprintf("%s/%s.%s.%s", strings.ToLower(path.Join(search...)), layout, name, suffix))
233-
layouts = append(layouts, fmt.Sprintf("%s/%s.%s", strings.ToLower(path.Join(search...)), layout, suffix))
257+
layouts = append(layouts, fmt.Sprintf("%s/%s.%s%s", strings.ToLower(path.Join(search...)), layout, name, suffix))
258+
layouts = append(layouts, fmt.Sprintf("%s/%s%s", strings.ToLower(path.Join(search...)), layout, suffix))
234259

235260
}
236261
}
237262

238263
// Add _default/layout.html
239-
layouts = append(layouts, fmt.Sprintf("_default/%s.%s.%s", layout, name, suffix))
240-
layouts = append(layouts, fmt.Sprintf("_default/%s.%s", layout, suffix))
264+
layouts = append(layouts, fmt.Sprintf("_default/%s.%s%s", layout, name, suffix))
265+
layouts = append(layouts, fmt.Sprintf("_default/%s%s", layout, suffix))
241266

242-
return layouts
267+
return filterDotLess(layouts)
243268
}

‎output/layout_test.go‎

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,34 @@ import (
2121
"github.com/stretchr/testify/require"
2222
)
2323

24-
var ampType = Format{
25-
Name: "AMP",
26-
MediaType: media.HTMLType,
27-
BaseName: "index",
28-
}
29-
3024
func TestLayout(t *testing.T) {
3125

26+
noExtNoDelimMediaType := media.TextType
27+
noExtNoDelimMediaType.Suffix = ""
28+
noExtNoDelimMediaType.Delimiter = ""
29+
30+
noExtMediaType := media.TextType
31+
noExtMediaType.Suffix = ""
32+
33+
var (
34+
ampType = Format{
35+
Name: "AMP",
36+
MediaType: media.HTMLType,
37+
BaseName: "index",
38+
}
39+
40+
noExtDelimFormat = Format{
41+
Name: "NEM",
42+
MediaType: noExtNoDelimMediaType,
43+
BaseName: "_redirects",
44+
}
45+
noExt = Format{
46+
Name: "NEX",
47+
MediaType: noExtMediaType,
48+
BaseName: "next",
49+
}
50+
)
51+
3252
for _, this := range []struct {
3353
name string
3454
d LayoutDescriptor
@@ -39,6 +59,12 @@ func TestLayout(t *testing.T) {
3959
}{
4060
{"Home", LayoutDescriptor{Kind: "home"}, true, "", ampType,
4161
[]string{"index.amp.html", "index.html", "_default/list.amp.html", "_default/list.html", "theme/index.amp.html", "theme/index.html"}},
62+
{"Home, no ext or delim", LayoutDescriptor{Kind: "home"}, true, "", noExtDelimFormat,
63+
[]string{"index.nem", "_default/list.nem"}},
64+
{"Home, no ext", LayoutDescriptor{Kind: "home"}, true, "", noExt,
65+
[]string{"index.nex", "_default/list.nex"}},
66+
{"Page, no ext or delim", LayoutDescriptor{Kind: "page"}, true, "", noExtDelimFormat,
67+
[]string{"_default/single.nem", "theme/_default/single.nem"}},
4268
{"Section", LayoutDescriptor{Kind: "section", Section: "sect1"}, false, "", ampType,
4369
[]string{"section/sect1.amp.html", "section/sect1.html"}},
4470
{"Taxonomy", LayoutDescriptor{Kind: "taxonomy", Section: "tag"}, false, "", ampType,

0 commit comments

Comments
 (0)