permalinks.go (10034B)
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 page 15 16 import ( 17 "fmt" 18 "os" 19 "path" 20 "path/filepath" 21 "regexp" 22 "strconv" 23 "strings" 24 "time" 25 26 "errors" 27 28 "github.com/gohugoio/hugo/helpers" 29 ) 30 31 // PermalinkExpander holds permalin mappings per section. 32 type PermalinkExpander struct { 33 // knownPermalinkAttributes maps :tags in a permalink specification to a 34 // function which, given a page and the tag, returns the resulting string 35 // to be used to replace that tag. 36 knownPermalinkAttributes map[string]pageToPermaAttribute 37 38 expanders map[string]func(Page) (string, error) 39 40 ps *helpers.PathSpec 41 } 42 43 // Time for checking date formats. Every field is different than the 44 // Go reference time for date formatting. This ensures that formatting this date 45 // with a Go time format always has a different output than the format itself. 46 var referenceTime = time.Date(2019, time.November, 9, 23, 1, 42, 1, time.UTC) 47 48 // Return the callback for the given permalink attribute and a boolean indicating if the attribute is valid or not. 49 func (p PermalinkExpander) callback(attr string) (pageToPermaAttribute, bool) { 50 if callback, ok := p.knownPermalinkAttributes[attr]; ok { 51 return callback, true 52 } 53 54 if strings.HasPrefix(attr, "sections[") { 55 fn := p.toSliceFunc(strings.TrimPrefix(attr, "sections")) 56 return func(p Page, s string) (string, error) { 57 return path.Join(fn(p.CurrentSection().SectionsEntries())...), nil 58 }, true 59 } 60 61 // Make sure this comes after all the other checks. 62 if referenceTime.Format(attr) != attr { 63 return p.pageToPermalinkDate, true 64 } 65 66 return nil, false 67 } 68 69 // NewPermalinkExpander creates a new PermalinkExpander configured by the given 70 // PathSpec. 71 func NewPermalinkExpander(ps *helpers.PathSpec) (PermalinkExpander, error) { 72 p := PermalinkExpander{ps: ps} 73 74 p.knownPermalinkAttributes = map[string]pageToPermaAttribute{ 75 "year": p.pageToPermalinkDate, 76 "month": p.pageToPermalinkDate, 77 "monthname": p.pageToPermalinkDate, 78 "day": p.pageToPermalinkDate, 79 "weekday": p.pageToPermalinkDate, 80 "weekdayname": p.pageToPermalinkDate, 81 "yearday": p.pageToPermalinkDate, 82 "section": p.pageToPermalinkSection, 83 "sections": p.pageToPermalinkSections, 84 "title": p.pageToPermalinkTitle, 85 "slug": p.pageToPermalinkSlugElseTitle, 86 "slugorfilename": p.pageToPermalinkSlugElseFilename, 87 "filename": p.pageToPermalinkFilename, 88 } 89 90 patterns := ps.Cfg.GetStringMapString("permalinks") 91 if patterns == nil { 92 return p, nil 93 } 94 95 e, err := p.parse(patterns) 96 if err != nil { 97 return p, err 98 } 99 100 p.expanders = e 101 102 return p, nil 103 } 104 105 // Expand expands the path in p according to the rules defined for the given key. 106 // If no rules are found for the given key, an empty string is returned. 107 func (l PermalinkExpander) Expand(key string, p Page) (string, error) { 108 expand, found := l.expanders[key] 109 110 if !found { 111 return "", nil 112 } 113 114 return expand(p) 115 } 116 117 func (l PermalinkExpander) parse(patterns map[string]string) (map[string]func(Page) (string, error), error) { 118 expanders := make(map[string]func(Page) (string, error)) 119 120 // Allow " " and / to represent the root section. 121 const sectionCutSet = " /" + string(os.PathSeparator) 122 123 for k, pattern := range patterns { 124 k = strings.Trim(k, sectionCutSet) 125 126 if !l.validate(pattern) { 127 return nil, &permalinkExpandError{pattern: pattern, err: errPermalinkIllFormed} 128 } 129 130 pattern := pattern 131 matches := attributeRegexp.FindAllStringSubmatch(pattern, -1) 132 133 callbacks := make([]pageToPermaAttribute, len(matches)) 134 replacements := make([]string, len(matches)) 135 for i, m := range matches { 136 replacement := m[0] 137 attr := replacement[1:] 138 replacements[i] = replacement 139 callback, ok := l.callback(attr) 140 141 if !ok { 142 return nil, &permalinkExpandError{pattern: pattern, err: errPermalinkAttributeUnknown} 143 } 144 145 callbacks[i] = callback 146 } 147 148 expanders[k] = func(p Page) (string, error) { 149 if matches == nil { 150 return pattern, nil 151 } 152 153 newField := pattern 154 155 for i, replacement := range replacements { 156 attr := replacement[1:] 157 callback := callbacks[i] 158 newAttr, err := callback(p, attr) 159 if err != nil { 160 return "", &permalinkExpandError{pattern: pattern, err: err} 161 } 162 163 newField = strings.Replace(newField, replacement, newAttr, 1) 164 165 } 166 167 return newField, nil 168 } 169 170 } 171 172 return expanders, nil 173 } 174 175 // pageToPermaAttribute is the type of a function which, given a page and a tag 176 // can return a string to go in that position in the page (or an error) 177 type pageToPermaAttribute func(Page, string) (string, error) 178 179 var attributeRegexp = regexp.MustCompile(`:\w+(\[.+\])?`) 180 181 // validate determines if a PathPattern is well-formed 182 func (l PermalinkExpander) validate(pp string) bool { 183 fragments := strings.Split(pp[1:], "/") 184 bail := false 185 for i := range fragments { 186 if bail { 187 return false 188 } 189 if len(fragments[i]) == 0 { 190 bail = true 191 continue 192 } 193 194 matches := attributeRegexp.FindAllStringSubmatch(fragments[i], -1) 195 if matches == nil { 196 continue 197 } 198 199 for _, match := range matches { 200 k := match[0][1:] 201 if _, ok := l.callback(k); !ok { 202 return false 203 } 204 } 205 } 206 return true 207 } 208 209 type permalinkExpandError struct { 210 pattern string 211 err error 212 } 213 214 func (pee *permalinkExpandError) Error() string { 215 return fmt.Sprintf("error expanding %q: %s", pee.pattern, pee.err) 216 } 217 218 var ( 219 errPermalinkIllFormed = errors.New("permalink ill-formed") 220 errPermalinkAttributeUnknown = errors.New("permalink attribute not recognised") 221 ) 222 223 func (l PermalinkExpander) pageToPermalinkDate(p Page, dateField string) (string, error) { 224 // a Page contains a Node which provides a field Date, time.Time 225 switch dateField { 226 case "year": 227 return strconv.Itoa(p.Date().Year()), nil 228 case "month": 229 return fmt.Sprintf("%02d", int(p.Date().Month())), nil 230 case "monthname": 231 return p.Date().Month().String(), nil 232 case "day": 233 return fmt.Sprintf("%02d", p.Date().Day()), nil 234 case "weekday": 235 return strconv.Itoa(int(p.Date().Weekday())), nil 236 case "weekdayname": 237 return p.Date().Weekday().String(), nil 238 case "yearday": 239 return strconv.Itoa(p.Date().YearDay()), nil 240 } 241 242 return p.Date().Format(dateField), nil 243 } 244 245 // pageToPermalinkTitle returns the URL-safe form of the title 246 func (l PermalinkExpander) pageToPermalinkTitle(p Page, _ string) (string, error) { 247 return l.ps.URLize(p.Title()), nil 248 } 249 250 // pageToPermalinkFilename returns the URL-safe form of the filename 251 func (l PermalinkExpander) pageToPermalinkFilename(p Page, _ string) (string, error) { 252 name := p.File().TranslationBaseName() 253 if name == "index" { 254 // Page bundles; the directory name will hopefully have a better name. 255 dir := strings.TrimSuffix(p.File().Dir(), helpers.FilePathSeparator) 256 _, name = filepath.Split(dir) 257 } 258 259 return l.ps.URLize(name), nil 260 } 261 262 // if the page has a slug, return the slug, else return the title 263 func (l PermalinkExpander) pageToPermalinkSlugElseTitle(p Page, a string) (string, error) { 264 if p.Slug() != "" { 265 return l.ps.URLize(p.Slug()), nil 266 } 267 return l.pageToPermalinkTitle(p, a) 268 } 269 270 // if the page has a slug, return the slug, else return the filename 271 func (l PermalinkExpander) pageToPermalinkSlugElseFilename(p Page, a string) (string, error) { 272 if p.Slug() != "" { 273 return l.ps.URLize(p.Slug()), nil 274 } 275 return l.pageToPermalinkFilename(p, a) 276 } 277 278 func (l PermalinkExpander) pageToPermalinkSection(p Page, _ string) (string, error) { 279 return p.Section(), nil 280 } 281 282 func (l PermalinkExpander) pageToPermalinkSections(p Page, _ string) (string, error) { 283 return p.CurrentSection().SectionsPath(), nil 284 } 285 286 var ( 287 nilSliceFunc = func(s []string) []string { 288 return nil 289 } 290 allSliceFunc = func(s []string) []string { 291 return s 292 } 293 ) 294 295 // toSliceFunc returns a slice func that slices s according to the cut spec. 296 // The cut spec must be on form [low:high] (one or both can be omitted), 297 // also allowing single slice indices (e.g. [2]) and the special [last] keyword 298 // giving the last element of the slice. 299 // The returned function will be lenient and not panic in out of bounds situation. 300 // 301 // The current use case for this is to use parts of the sections path in permalinks. 302 func (l PermalinkExpander) toSliceFunc(cut string) func(s []string) []string { 303 cut = strings.ToLower(strings.TrimSpace(cut)) 304 if cut == "" { 305 return allSliceFunc 306 } 307 308 if len(cut) < 3 || (cut[0] != '[' || cut[len(cut)-1] != ']') { 309 return nilSliceFunc 310 } 311 312 toNFunc := func(s string, low bool) func(ss []string) int { 313 if s == "" { 314 if low { 315 return func(ss []string) int { 316 return 0 317 } 318 } else { 319 return func(ss []string) int { 320 return len(ss) 321 } 322 } 323 } 324 325 if s == "last" { 326 return func(ss []string) int { 327 return len(ss) - 1 328 } 329 } 330 331 n, _ := strconv.Atoi(s) 332 if n < 0 { 333 n = 0 334 } 335 return func(ss []string) int { 336 // Prevent out of bound situations. It would not make 337 // much sense to panic here. 338 if n > len(ss) { 339 return len(ss) 340 } 341 return n 342 } 343 } 344 345 opsStr := cut[1 : len(cut)-1] 346 opts := strings.Split(opsStr, ":") 347 348 if !strings.Contains(opsStr, ":") { 349 toN := toNFunc(opts[0], true) 350 return func(s []string) []string { 351 if len(s) == 0 { 352 return nil 353 } 354 v := s[toN(s)] 355 if v == "" { 356 return nil 357 } 358 return []string{v} 359 } 360 } 361 362 toN1, toN2 := toNFunc(opts[0], true), toNFunc(opts[1], false) 363 364 return func(s []string) []string { 365 if len(s) == 0 { 366 return nil 367 } 368 return s[toN1(s):toN2(s)] 369 } 370 371 }