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