1// Copyright 2019 The Hugo Authors. All rights reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6// http://www.apache.org/licenses/LICENSE-2.0
7//
8// Unless required by applicable law or agreed to in writing, software
9// distributed under the License is distributed on an "AS IS" BASIS,
10// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11// See the License for the specific language governing permissions and
12// limitations under the License.
13
14package output
15
16import (
17	"encoding/json"
18	"fmt"
19	"reflect"
20	"sort"
21	"strings"
22
23	"github.com/pkg/errors"
24
25	"github.com/mitchellh/mapstructure"
26
27	"github.com/gohugoio/hugo/media"
28)
29
30// Format represents an output representation, usually to a file on disk.
31type Format struct {
32	// The Name is used as an identifier. Internal output formats (i.e. HTML and RSS)
33	// can be overridden by providing a new definition for those types.
34	Name string `json:"name"`
35
36	MediaType media.Type `json:"-"`
37
38	// Must be set to a value when there are two or more conflicting mediatype for the same resource.
39	Path string `json:"path"`
40
41	// The base output file name used when not using "ugly URLs", defaults to "index".
42	BaseName string `json:"baseName"`
43
44	// The value to use for rel links
45	//
46	// See https://www.w3schools.com/tags/att_link_rel.asp
47	//
48	// AMP has a special requirement in this department, see:
49	// https://www.ampproject.org/docs/guides/deploy/discovery
50	// I.e.:
51	// <link rel="amphtml" href="https://www.example.com/url/to/amp/document.html">
52	Rel string `json:"rel"`
53
54	// The protocol to use, i.e. "webcal://". Defaults to the protocol of the baseURL.
55	Protocol string `json:"protocol"`
56
57	// IsPlainText decides whether to use text/template or html/template
58	// as template parser.
59	IsPlainText bool `json:"isPlainText"`
60
61	// IsHTML returns whether this format is int the HTML family. This includes
62	// HTML, AMP etc. This is used to decide when to create alias redirects etc.
63	IsHTML bool `json:"isHTML"`
64
65	// Enable to ignore the global uglyURLs setting.
66	NoUgly bool `json:"noUgly"`
67
68	// Enable if it doesn't make sense to include this format in an alternative
69	// format listing, CSS being one good example.
70	// Note that we use the term "alternative" and not "alternate" here, as it
71	// does not necessarily replace the other format, it is an alternative representation.
72	NotAlternative bool `json:"notAlternative"`
73
74	// Setting this will make this output format control the value of
75	// .Permalink and .RelPermalink for a rendered Page.
76	// If not set, these values will point to the main (first) output format
77	// configured. That is probably the behaviour you want in most situations,
78	// as you probably don't want to link back to the RSS version of a page, as an
79	// example. AMP would, however, be a good example of an output format where this
80	// behaviour is wanted.
81	Permalinkable bool `json:"permalinkable"`
82
83	// Setting this to a non-zero value will be used as the first sort criteria.
84	Weight int `json:"weight"`
85}
86
87// An ordered list of built-in output formats.
88var (
89	AMPFormat = Format{
90		Name:          "AMP",
91		MediaType:     media.HTMLType,
92		BaseName:      "index",
93		Path:          "amp",
94		Rel:           "amphtml",
95		IsHTML:        true,
96		Permalinkable: true,
97		// See https://www.ampproject.org/learn/overview/
98	}
99
100	CalendarFormat = Format{
101		Name:        "Calendar",
102		MediaType:   media.CalendarType,
103		IsPlainText: true,
104		Protocol:    "webcal://",
105		BaseName:    "index",
106		Rel:         "alternate",
107	}
108
109	CSSFormat = Format{
110		Name:           "CSS",
111		MediaType:      media.CSSType,
112		BaseName:       "styles",
113		IsPlainText:    true,
114		Rel:            "stylesheet",
115		NotAlternative: true,
116	}
117	CSVFormat = Format{
118		Name:        "CSV",
119		MediaType:   media.CSVType,
120		BaseName:    "index",
121		IsPlainText: true,
122		Rel:         "alternate",
123	}
124
125	HTMLFormat = Format{
126		Name:          "HTML",
127		MediaType:     media.HTMLType,
128		BaseName:      "index",
129		Rel:           "canonical",
130		IsHTML:        true,
131		Permalinkable: true,
132
133		// Weight will be used as first sort criteria. HTML will, by default,
134		// be rendered first, but set it to 10 so it's easy to put one above it.
135		Weight: 10,
136	}
137
138	JSONFormat = Format{
139		Name:        "JSON",
140		MediaType:   media.JSONType,
141		BaseName:    "index",
142		IsPlainText: true,
143		Rel:         "alternate",
144	}
145
146	WebAppManifestFormat = Format{
147		Name:           "WebAppManifest",
148		MediaType:      media.WebAppManifestType,
149		BaseName:       "manifest",
150		IsPlainText:    true,
151		NotAlternative: true,
152		Rel:            "manifest",
153	}
154
155	RobotsTxtFormat = Format{
156		Name:        "ROBOTS",
157		MediaType:   media.TextType,
158		BaseName:    "robots",
159		IsPlainText: true,
160		Rel:         "alternate",
161	}
162
163	RSSFormat = Format{
164		Name:      "RSS",
165		MediaType: media.RSSType,
166		BaseName:  "index",
167		NoUgly:    true,
168		Rel:       "alternate",
169	}
170
171	SitemapFormat = Format{
172		Name:      "Sitemap",
173		MediaType: media.XMLType,
174		BaseName:  "sitemap",
175		NoUgly:    true,
176		Rel:       "sitemap",
177	}
178)
179
180// DefaultFormats contains the default output formats supported by Hugo.
181var DefaultFormats = Formats{
182	AMPFormat,
183	CalendarFormat,
184	CSSFormat,
185	CSVFormat,
186	HTMLFormat,
187	JSONFormat,
188	WebAppManifestFormat,
189	RobotsTxtFormat,
190	RSSFormat,
191	SitemapFormat,
192}
193
194func init() {
195	sort.Sort(DefaultFormats)
196}
197
198// Formats is a slice of Format.
199type Formats []Format
200
201func (formats Formats) Len() int      { return len(formats) }
202func (formats Formats) Swap(i, j int) { formats[i], formats[j] = formats[j], formats[i] }
203func (formats Formats) Less(i, j int) bool {
204	fi, fj := formats[i], formats[j]
205	if fi.Weight == fj.Weight {
206		return fi.Name < fj.Name
207	}
208
209	if fj.Weight == 0 {
210		return true
211	}
212
213	return fi.Weight > 0 && fi.Weight < fj.Weight
214}
215
216// GetBySuffix gets a output format given as suffix, e.g. "html".
217// It will return false if no format could be found, or if the suffix given
218// is ambiguous.
219// The lookup is case insensitive.
220func (formats Formats) GetBySuffix(suffix string) (f Format, found bool) {
221	for _, ff := range formats {
222		for _, suffix2 := range ff.MediaType.Suffixes() {
223			if strings.EqualFold(suffix, suffix2) {
224				if found {
225					// ambiguous
226					found = false
227					return
228				}
229				f = ff
230				found = true
231			}
232		}
233	}
234	return
235}
236
237// GetByName gets a format by its identifier name.
238func (formats Formats) GetByName(name string) (f Format, found bool) {
239	for _, ff := range formats {
240		if strings.EqualFold(name, ff.Name) {
241			f = ff
242			found = true
243			return
244		}
245	}
246	return
247}
248
249// GetByNames gets a list of formats given a list of identifiers.
250func (formats Formats) GetByNames(names ...string) (Formats, error) {
251	var types []Format
252
253	for _, name := range names {
254		tpe, ok := formats.GetByName(name)
255		if !ok {
256			return types, fmt.Errorf("OutputFormat with key %q not found", name)
257		}
258		types = append(types, tpe)
259	}
260	return types, nil
261}
262
263// FromFilename gets a Format given a filename.
264func (formats Formats) FromFilename(filename string) (f Format, found bool) {
265	// mytemplate.amp.html
266	// mytemplate.html
267	// mytemplate
268	var ext, outFormat string
269
270	parts := strings.Split(filename, ".")
271	if len(parts) > 2 {
272		outFormat = parts[1]
273		ext = parts[2]
274	} else if len(parts) > 1 {
275		ext = parts[1]
276	}
277
278	if outFormat != "" {
279		return formats.GetByName(outFormat)
280	}
281
282	if ext != "" {
283		f, found = formats.GetBySuffix(ext)
284		if !found && len(parts) == 2 {
285			// For extensionless output formats (e.g. Netlify's _redirects)
286			// we must fall back to using the extension as format lookup.
287			f, found = formats.GetByName(ext)
288		}
289	}
290	return
291}
292
293// DecodeFormats takes a list of output format configurations and merges those,
294// in the order given, with the Hugo defaults as the last resort.
295func DecodeFormats(mediaTypes media.Types, maps ...map[string]interface{}) (Formats, error) {
296	f := make(Formats, len(DefaultFormats))
297	copy(f, DefaultFormats)
298
299	for _, m := range maps {
300		for k, v := range m {
301			found := false
302			for i, vv := range f {
303				if strings.EqualFold(k, vv.Name) {
304					// Merge it with the existing
305					if err := decode(mediaTypes, v, &f[i]); err != nil {
306						return f, err
307					}
308					found = true
309				}
310			}
311			if !found {
312				var newOutFormat Format
313				newOutFormat.Name = k
314				if err := decode(mediaTypes, v, &newOutFormat); err != nil {
315					return f, err
316				}
317
318				// We need values for these
319				if newOutFormat.BaseName == "" {
320					newOutFormat.BaseName = "index"
321				}
322				if newOutFormat.Rel == "" {
323					newOutFormat.Rel = "alternate"
324				}
325
326				f = append(f, newOutFormat)
327
328			}
329		}
330	}
331
332	sort.Sort(f)
333
334	return f, nil
335}
336
337func decode(mediaTypes media.Types, input interface{}, output *Format) error {
338	config := &mapstructure.DecoderConfig{
339		Metadata:         nil,
340		Result:           output,
341		WeaklyTypedInput: true,
342		DecodeHook: func(a reflect.Type, b reflect.Type, c interface{}) (interface{}, error) {
343			if a.Kind() == reflect.Map {
344				dataVal := reflect.Indirect(reflect.ValueOf(c))
345				for _, key := range dataVal.MapKeys() {
346					keyStr, ok := key.Interface().(string)
347					if !ok {
348						// Not a string key
349						continue
350					}
351					if strings.EqualFold(keyStr, "mediaType") {
352						// If mediaType is a string, look it up and replace it
353						// in the map.
354						vv := dataVal.MapIndex(key)
355						vvi := vv.Interface()
356
357						switch vviv := vvi.(type) {
358						case media.Type:
359						// OK
360						case string:
361							mediaType, found := mediaTypes.GetByType(vviv)
362							if !found {
363								return c, fmt.Errorf("media type %q not found", vviv)
364							}
365							dataVal.SetMapIndex(key, reflect.ValueOf(mediaType))
366						default:
367							return nil, errors.Errorf("invalid output format configuration; wrong type for media type, expected string (e.g. text/html), got %T", vvi)
368						}
369					}
370				}
371			}
372			return c, nil
373		},
374	}
375
376	decoder, err := mapstructure.NewDecoder(config)
377	if err != nil {
378		return err
379	}
380
381	if err = decoder.Decode(input); err != nil {
382		return errors.Wrap(err, "failed to decode output format configuration")
383	}
384
385	return nil
386
387}
388
389// BaseFilename returns the base filename of f including an extension (ie.
390// "index.xml").
391func (f Format) BaseFilename() string {
392	return f.BaseName + f.MediaType.FirstSuffix.FullSuffix
393}
394
395// MarshalJSON returns the JSON encoding of f.
396func (f Format) MarshalJSON() ([]byte, error) {
397	type Alias Format
398	return json.Marshal(&struct {
399		MediaType string `json:"mediaType"`
400		Alias
401	}{
402		MediaType: f.MediaType.String(),
403		Alias:     (Alias)(f),
404	})
405}
406