github.com/graemephi/kahugo@v0.62.3-0.20211121071557-d78c0423784d/resources/page/permalinks.go (about)

     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  	"github.com/pkg/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  		"filename":    p.pageToPermalinkFilename,
    87  	}
    88  
    89  	patterns := ps.Cfg.GetStringMapString("permalinks")
    90  	if patterns == nil {
    91  		return p, nil
    92  	}
    93  
    94  	e, err := p.parse(patterns)
    95  	if err != nil {
    96  		return p, err
    97  	}
    98  
    99  	p.expanders = e
   100  
   101  	return p, nil
   102  }
   103  
   104  // Expand expands the path in p according to the rules defined for the given key.
   105  // If no rules are found for the given key, an empty string is returned.
   106  func (l PermalinkExpander) Expand(key string, p Page) (string, error) {
   107  	expand, found := l.expanders[key]
   108  
   109  	if !found {
   110  		return "", nil
   111  	}
   112  
   113  	return expand(p)
   114  }
   115  
   116  func (l PermalinkExpander) parse(patterns map[string]string) (map[string]func(Page) (string, error), error) {
   117  	expanders := make(map[string]func(Page) (string, error))
   118  
   119  	// Allow " " and / to represent the root section.
   120  	const sectionCutSet = " /" + string(os.PathSeparator)
   121  
   122  	for k, pattern := range patterns {
   123  		k = strings.Trim(k, sectionCutSet)
   124  
   125  		if !l.validate(pattern) {
   126  			return nil, &permalinkExpandError{pattern: pattern, err: errPermalinkIllFormed}
   127  		}
   128  
   129  		pattern := pattern
   130  		matches := attributeRegexp.FindAllStringSubmatch(pattern, -1)
   131  
   132  		callbacks := make([]pageToPermaAttribute, len(matches))
   133  		replacements := make([]string, len(matches))
   134  		for i, m := range matches {
   135  			replacement := m[0]
   136  			attr := replacement[1:]
   137  			replacements[i] = replacement
   138  			callback, ok := l.callback(attr)
   139  
   140  			if !ok {
   141  				return nil, &permalinkExpandError{pattern: pattern, err: errPermalinkAttributeUnknown}
   142  			}
   143  
   144  			callbacks[i] = callback
   145  		}
   146  
   147  		expanders[k] = func(p Page) (string, error) {
   148  			if matches == nil {
   149  				return pattern, nil
   150  			}
   151  
   152  			newField := pattern
   153  
   154  			for i, replacement := range replacements {
   155  				attr := replacement[1:]
   156  				callback := callbacks[i]
   157  				newAttr, err := callback(p, attr)
   158  				if err != nil {
   159  					return "", &permalinkExpandError{pattern: pattern, err: err}
   160  				}
   161  
   162  				newField = strings.Replace(newField, replacement, newAttr, 1)
   163  
   164  			}
   165  
   166  			return newField, nil
   167  		}
   168  
   169  	}
   170  
   171  	return expanders, nil
   172  }
   173  
   174  // pageToPermaAttribute is the type of a function which, given a page and a tag
   175  // can return a string to go in that position in the page (or an error)
   176  type pageToPermaAttribute func(Page, string) (string, error)
   177  
   178  var attributeRegexp = regexp.MustCompile(`:\w+(\[.+\])?`)
   179  
   180  // validate determines if a PathPattern is well-formed
   181  func (l PermalinkExpander) validate(pp string) bool {
   182  	fragments := strings.Split(pp[1:], "/")
   183  	bail := false
   184  	for i := range fragments {
   185  		if bail {
   186  			return false
   187  		}
   188  		if len(fragments[i]) == 0 {
   189  			bail = true
   190  			continue
   191  		}
   192  
   193  		matches := attributeRegexp.FindAllStringSubmatch(fragments[i], -1)
   194  		if matches == nil {
   195  			continue
   196  		}
   197  
   198  		for _, match := range matches {
   199  			k := match[0][1:]
   200  			if _, ok := l.callback(k); !ok {
   201  				return false
   202  			}
   203  		}
   204  	}
   205  	return true
   206  }
   207  
   208  type permalinkExpandError struct {
   209  	pattern string
   210  	err     error
   211  }
   212  
   213  func (pee *permalinkExpandError) Error() string {
   214  	return fmt.Sprintf("error expanding %q: %s", pee.pattern, pee.err)
   215  }
   216  
   217  var (
   218  	errPermalinkIllFormed        = errors.New("permalink ill-formed")
   219  	errPermalinkAttributeUnknown = errors.New("permalink attribute not recognised")
   220  )
   221  
   222  func (l PermalinkExpander) pageToPermalinkDate(p Page, dateField string) (string, error) {
   223  	// a Page contains a Node which provides a field Date, time.Time
   224  	switch dateField {
   225  	case "year":
   226  		return strconv.Itoa(p.Date().Year()), nil
   227  	case "month":
   228  		return fmt.Sprintf("%02d", int(p.Date().Month())), nil
   229  	case "monthname":
   230  		return p.Date().Month().String(), nil
   231  	case "day":
   232  		return fmt.Sprintf("%02d", p.Date().Day()), nil
   233  	case "weekday":
   234  		return strconv.Itoa(int(p.Date().Weekday())), nil
   235  	case "weekdayname":
   236  		return p.Date().Weekday().String(), nil
   237  	case "yearday":
   238  		return strconv.Itoa(p.Date().YearDay()), nil
   239  	}
   240  
   241  	return p.Date().Format(dateField), nil
   242  }
   243  
   244  // pageToPermalinkTitle returns the URL-safe form of the title
   245  func (l PermalinkExpander) pageToPermalinkTitle(p Page, _ string) (string, error) {
   246  	return l.ps.URLize(p.Title()), nil
   247  }
   248  
   249  // pageToPermalinkFilename returns the URL-safe form of the filename
   250  func (l PermalinkExpander) pageToPermalinkFilename(p Page, _ string) (string, error) {
   251  	name := p.File().TranslationBaseName()
   252  	if name == "index" {
   253  		// Page bundles; the directory name will hopefully have a better name.
   254  		dir := strings.TrimSuffix(p.File().Dir(), helpers.FilePathSeparator)
   255  		_, name = filepath.Split(dir)
   256  	}
   257  
   258  	return l.ps.URLize(name), nil
   259  }
   260  
   261  // if the page has a slug, return the slug, else return the title
   262  func (l PermalinkExpander) pageToPermalinkSlugElseTitle(p Page, a string) (string, error) {
   263  	if p.Slug() != "" {
   264  		return l.ps.URLize(p.Slug()), nil
   265  	}
   266  	return l.pageToPermalinkTitle(p, a)
   267  }
   268  
   269  func (l PermalinkExpander) pageToPermalinkSection(p Page, _ string) (string, error) {
   270  	return p.Section(), nil
   271  }
   272  
   273  func (l PermalinkExpander) pageToPermalinkSections(p Page, _ string) (string, error) {
   274  	return p.CurrentSection().SectionsPath(), nil
   275  }
   276  
   277  var (
   278  	nilSliceFunc = func(s []string) []string {
   279  		return nil
   280  	}
   281  	allSliceFunc = func(s []string) []string {
   282  		return s
   283  	}
   284  )
   285  
   286  // toSliceFunc returns a slice func that slices s according to the cut spec.
   287  // The cut spec must be on form [low:high] (one or both can be omitted),
   288  // also allowing single slice indices (e.g. [2]) and the special [last] keyword
   289  // giving the last element of the slice.
   290  // The returned function will be lenient and not panic in out of bounds situation.
   291  //
   292  // The current use case for this is to use parts of the sections path in permalinks.
   293  func (l PermalinkExpander) toSliceFunc(cut string) func(s []string) []string {
   294  	cut = strings.ToLower(strings.TrimSpace(cut))
   295  	if cut == "" {
   296  		return allSliceFunc
   297  	}
   298  
   299  	if len(cut) < 3 || (cut[0] != '[' || cut[len(cut)-1] != ']') {
   300  		return nilSliceFunc
   301  	}
   302  
   303  	toNFunc := func(s string, low bool) func(ss []string) int {
   304  		if s == "" {
   305  			if low {
   306  				return func(ss []string) int {
   307  					return 0
   308  				}
   309  			} else {
   310  				return func(ss []string) int {
   311  					return len(ss)
   312  				}
   313  			}
   314  		}
   315  
   316  		if s == "last" {
   317  			return func(ss []string) int {
   318  				return len(ss) - 1
   319  			}
   320  		}
   321  
   322  		n, _ := strconv.Atoi(s)
   323  		if n < 0 {
   324  			n = 0
   325  		}
   326  		return func(ss []string) int {
   327  			// Prevent out of bound situations. It would not make
   328  			// much sense to panic here.
   329  			if n > len(ss) {
   330  				return len(ss)
   331  			}
   332  			return n
   333  		}
   334  	}
   335  
   336  	opsStr := cut[1 : len(cut)-1]
   337  	opts := strings.Split(opsStr, ":")
   338  
   339  	if !strings.Contains(opsStr, ":") {
   340  		toN := toNFunc(opts[0], true)
   341  		return func(s []string) []string {
   342  			if len(s) == 0 {
   343  				return nil
   344  			}
   345  			v := s[toN(s)]
   346  			if v == "" {
   347  				return nil
   348  			}
   349  			return []string{v}
   350  		}
   351  	}
   352  
   353  	toN1, toN2 := toNFunc(opts[0], true), toNFunc(opts[1], false)
   354  
   355  	return func(s []string) []string {
   356  		if len(s) == 0 {
   357  			return nil
   358  		}
   359  		return s[toN1(s):toN2(s)]
   360  	}
   361  
   362  }