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 }