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