hugo/resources/page/permalinks.go
Bjørn Erik Pedersen 6e1c5b61b3 resources/page: Adjust the permalinks colon implementation a little
Mostly to get back to an attribute regexp that's reasonably simle to read/understand.

Updates #12918
2024-10-15 10:28:00 +02:00

497 lines
14 KiB
Go

// Copyright 2019 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package page
import (
"errors"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/gohugoio/hugo/common/hstrings"
"github.com/gohugoio/hugo/common/maps"
"github.com/gohugoio/hugo/helpers"
"github.com/gohugoio/hugo/resources/kinds"
)
// PermalinkExpander holds permalink mappings per section.
type PermalinkExpander struct {
// knownPermalinkAttributes maps :tags in a permalink specification to a
// function which, given a page and the tag, returns the resulting string
// to be used to replace that tag.
knownPermalinkAttributes map[string]pageToPermaAttribute
expanders map[string]map[string]func(Page) (string, error)
urlize func(uri string) string
patternCache *maps.Cache[string, func(Page) (string, error)]
}
// Time for checking date formats. Every field is different than the
// Go reference time for date formatting. This ensures that formatting this date
// with a Go time format always has a different output than the format itself.
var referenceTime = time.Date(2019, time.November, 9, 23, 1, 42, 1, time.UTC)
// Return the callback for the given permalink attribute and a boolean indicating if the attribute is valid or not.
func (p PermalinkExpander) callback(attr string) (pageToPermaAttribute, bool) {
if callback, ok := p.knownPermalinkAttributes[attr]; ok {
return callback, true
}
if strings.HasPrefix(attr, "sections[") {
fn := p.toSliceFunc(strings.TrimPrefix(attr, "sections"))
return func(p Page, s string) (string, error) {
return path.Join(fn(p.CurrentSection().SectionsEntries())...), nil
}, true
}
// Make sure this comes after all the other checks.
if referenceTime.Format(attr) != attr {
return p.pageToPermalinkDate, true
}
return nil, false
}
// NewPermalinkExpander creates a new PermalinkExpander configured by the given
// urlize func.
func NewPermalinkExpander(urlize func(uri string) string, patterns map[string]map[string]string) (PermalinkExpander, error) {
p := PermalinkExpander{
urlize: urlize,
patternCache: maps.NewCache[string, func(Page) (string, error)](),
}
p.knownPermalinkAttributes = map[string]pageToPermaAttribute{
"year": p.pageToPermalinkDate,
"month": p.pageToPermalinkDate,
"monthname": p.pageToPermalinkDate,
"day": p.pageToPermalinkDate,
"weekday": p.pageToPermalinkDate,
"weekdayname": p.pageToPermalinkDate,
"yearday": p.pageToPermalinkDate,
"section": p.pageToPermalinkSection,
"sections": p.pageToPermalinkSections,
"title": p.pageToPermalinkTitle,
"slug": p.pageToPermalinkSlugElseTitle,
"slugorfilename": p.pageToPermalinkSlugElseFilename,
"filename": p.pageToPermalinkFilename,
}
p.expanders = make(map[string]map[string]func(Page) (string, error))
for kind, patterns := range patterns {
e, err := p.parse(patterns)
if err != nil {
return p, err
}
p.expanders[kind] = e
}
return p, nil
}
// Escape sequence for colons in permalink patterns.
const escapePlaceholderColon = "\x00"
func (l PermalinkExpander) normalizeEscapeSequencesIn(s string) (string, bool) {
s2 := strings.ReplaceAll(s, "\\:", escapePlaceholderColon)
return s2, s2 != s
}
func (l PermalinkExpander) normalizeEscapeSequencesOut(result string) string {
return strings.ReplaceAll(result, escapePlaceholderColon, ":")
}
// ExpandPattern expands the path in p with the specified expand pattern.
func (l PermalinkExpander) ExpandPattern(pattern string, p Page) (string, error) {
expand, err := l.getOrParsePattern(pattern)
if err != nil {
return "", err
}
return expand(p)
}
// Expand expands the path in p according to the rules defined for the given key.
// If no rules are found for the given key, an empty string is returned.
func (l PermalinkExpander) Expand(key string, p Page) (string, error) {
expanders, found := l.expanders[p.Kind()]
if !found {
return "", nil
}
expand, found := expanders[key]
if !found {
return "", nil
}
return expand(p)
}
// Allow " " and / to represent the root section.
var sectionCutSet = " /"
func init() {
if string(os.PathSeparator) != "/" {
sectionCutSet += string(os.PathSeparator)
}
}
func (l PermalinkExpander) getOrParsePattern(pattern string) (func(Page) (string, error), error) {
return l.patternCache.GetOrCreate(pattern, func() (func(Page) (string, error), error) {
if !l.validate(pattern) {
return nil, &permalinkExpandError{pattern: pattern, err: errPermalinkIllFormed}
}
var normalized bool
pattern, normalized = l.normalizeEscapeSequencesIn(pattern)
matches := attributeRegexp.FindAllStringSubmatch(pattern, -1)
if matches == nil {
result := pattern
if normalized {
result = l.normalizeEscapeSequencesOut(result)
}
return func(p Page) (string, error) {
return result, nil
}, nil
}
callbacks := make([]pageToPermaAttribute, len(matches))
replacements := make([]string, len(matches))
for i, m := range matches {
replacement := m[0]
attr := replacement[1:]
replacements[i] = replacement
callback, ok := l.callback(attr)
if !ok {
return nil, &permalinkExpandError{pattern: pattern, err: errPermalinkAttributeUnknown}
}
callbacks[i] = callback
}
return func(p Page) (string, error) {
newField := pattern
for i, replacement := range replacements {
attr := replacement[1:]
callback := callbacks[i]
newAttr, err := callback(p, attr)
if err != nil {
return "", &permalinkExpandError{pattern: pattern, err: err}
}
newField = strings.Replace(newField, replacement, newAttr, 1)
}
if normalized {
newField = l.normalizeEscapeSequencesOut(newField)
}
return newField, nil
}, nil
})
}
func (l PermalinkExpander) parse(patterns map[string]string) (map[string]func(Page) (string, error), error) {
expanders := make(map[string]func(Page) (string, error))
for k, pattern := range patterns {
k = strings.Trim(k, sectionCutSet)
expander, err := l.getOrParsePattern(pattern)
if err != nil {
return nil, err
}
expanders[k] = expander
}
return expanders, nil
}
// pageToPermaAttribute is the type of a function which, given a page and a tag
// can return a string to go in that position in the page (or an error)
type pageToPermaAttribute func(Page, string) (string, error)
var attributeRegexp = regexp.MustCompile(`:\w+(\[.+?\])?`)
// validate determines if a PathPattern is well-formed
func (l PermalinkExpander) validate(pp string) bool {
if len(pp) == 0 {
return false
}
fragments := strings.Split(pp[1:], "/")
bail := false
for i := range fragments {
if bail {
return false
}
if len(fragments[i]) == 0 {
bail = true
continue
}
matches := attributeRegexp.FindAllStringSubmatch(fragments[i], -1)
if matches == nil {
continue
}
for _, match := range matches {
k := match[0][1:]
if _, ok := l.callback(k); !ok {
return false
}
}
}
return true
}
type permalinkExpandError struct {
pattern string
err error
}
func (pee *permalinkExpandError) Error() string {
return fmt.Sprintf("error expanding %q: %s", pee.pattern, pee.err)
}
var (
errPermalinkIllFormed = errors.New("permalink ill-formed")
errPermalinkAttributeUnknown = errors.New("permalink attribute not recognised")
)
func (l PermalinkExpander) pageToPermalinkDate(p Page, dateField string) (string, error) {
// a Page contains a Node which provides a field Date, time.Time
switch dateField {
case "year":
return strconv.Itoa(p.Date().Year()), nil
case "month":
return fmt.Sprintf("%02d", int(p.Date().Month())), nil
case "monthname":
return p.Date().Month().String(), nil
case "day":
return fmt.Sprintf("%02d", p.Date().Day()), nil
case "weekday":
return strconv.Itoa(int(p.Date().Weekday())), nil
case "weekdayname":
return p.Date().Weekday().String(), nil
case "yearday":
return strconv.Itoa(p.Date().YearDay()), nil
}
return p.Date().Format(dateField), nil
}
// pageToPermalinkTitle returns the URL-safe form of the title
func (l PermalinkExpander) pageToPermalinkTitle(p Page, _ string) (string, error) {
return l.urlize(p.Title()), nil
}
// pageToPermalinkFilename returns the URL-safe form of the filename
func (l PermalinkExpander) pageToPermalinkFilename(p Page, _ string) (string, error) {
name := l.translationBaseName(p)
if name == "index" {
// Page bundles; the directory name will hopefully have a better name.
dir := strings.TrimSuffix(p.File().Dir(), helpers.FilePathSeparator)
_, name = filepath.Split(dir)
} else if name == "_index" {
return "", nil
}
return l.urlize(name), nil
}
// if the page has a slug, return the slug, else return the title
func (l PermalinkExpander) pageToPermalinkSlugElseTitle(p Page, a string) (string, error) {
if p.Slug() != "" {
return l.urlize(p.Slug()), nil
}
return l.pageToPermalinkTitle(p, a)
}
// if the page has a slug, return the slug, else return the filename
func (l PermalinkExpander) pageToPermalinkSlugElseFilename(p Page, a string) (string, error) {
if p.Slug() != "" {
return l.urlize(p.Slug()), nil
}
return l.pageToPermalinkFilename(p, a)
}
func (l PermalinkExpander) pageToPermalinkSection(p Page, _ string) (string, error) {
return p.Section(), nil
}
func (l PermalinkExpander) pageToPermalinkSections(p Page, _ string) (string, error) {
return p.CurrentSection().SectionsPath(), nil
}
func (l PermalinkExpander) translationBaseName(p Page) string {
if p.File() == nil {
return ""
}
return p.File().TranslationBaseName()
}
var (
nilSliceFunc = func(s []string) []string {
return nil
}
allSliceFunc = func(s []string) []string {
return s
}
)
// toSliceFunc returns a slice func that slices s according to the cut spec.
// The cut spec must be on form [low:high] (one or both can be omitted),
// also allowing single slice indices (e.g. [2]) and the special [last] keyword
// giving the last element of the slice.
// The returned function will be lenient and not panic in out of bounds situation.
//
// The current use case for this is to use parts of the sections path in permalinks.
func (l PermalinkExpander) toSliceFunc(cut string) func(s []string) []string {
cut = strings.ToLower(strings.TrimSpace(cut))
if cut == "" {
return allSliceFunc
}
if len(cut) < 3 || (cut[0] != '[' || cut[len(cut)-1] != ']') {
return nilSliceFunc
}
toNFunc := func(s string, low bool) func(ss []string) int {
if s == "" {
if low {
return func(ss []string) int {
return 0
}
} else {
return func(ss []string) int {
return len(ss)
}
}
}
if s == "last" {
return func(ss []string) int {
return len(ss) - 1
}
}
n, _ := strconv.Atoi(s)
if n < 0 {
n = 0
}
return func(ss []string) int {
// Prevent out of bound situations. It would not make
// much sense to panic here.
if n >= len(ss) {
if low {
return -1
}
return len(ss)
}
return n
}
}
opsStr := cut[1 : len(cut)-1]
opts := strings.Split(opsStr, ":")
if !strings.Contains(opsStr, ":") {
toN := toNFunc(opts[0], true)
return func(s []string) []string {
if len(s) == 0 {
return nil
}
n := toN(s)
if n < 0 {
return []string{}
}
v := s[n]
if v == "" {
return nil
}
return []string{v}
}
}
toN1, toN2 := toNFunc(opts[0], true), toNFunc(opts[1], false)
return func(s []string) []string {
if len(s) == 0 {
return nil
}
n1, n2 := toN1(s), toN2(s)
if n1 < 0 || n2 < 0 {
return []string{}
}
return s[n1:n2]
}
}
var permalinksKindsSupport = []string{kinds.KindPage, kinds.KindSection, kinds.KindTaxonomy, kinds.KindTerm}
// DecodePermalinksConfig decodes the permalinks configuration in the given map
func DecodePermalinksConfig(m map[string]any) (map[string]map[string]string, error) {
permalinksConfig := make(map[string]map[string]string)
permalinksConfig[kinds.KindPage] = make(map[string]string)
permalinksConfig[kinds.KindSection] = make(map[string]string)
permalinksConfig[kinds.KindTaxonomy] = make(map[string]string)
permalinksConfig[kinds.KindTerm] = make(map[string]string)
config := maps.CleanConfigStringMap(m)
for k, v := range config {
switch v := v.(type) {
case string:
// [permalinks]
// key = '...'
// To successfully be backward compatible, "default" patterns need to be set for both page and term
permalinksConfig[kinds.KindPage][k] = v
permalinksConfig[kinds.KindTerm][k] = v
case maps.Params:
// [permalinks.key]
// xyz = ???
if hstrings.InSlice(permalinksKindsSupport, k) {
// TODO: warn if we overwrite an already set value
for k2, v2 := range v {
switch v2 := v2.(type) {
case string:
permalinksConfig[k][k2] = v2
default:
return nil, fmt.Errorf("permalinks configuration invalid: unknown value %q for key %q for kind %q", v2, k2, k)
}
}
} else {
return nil, fmt.Errorf("permalinks configuration not supported for kind %q, supported kinds are %v", k, permalinksKindsSupport)
}
default:
return nil, fmt.Errorf("permalinks configuration invalid: unknown value %q for key %q", v, k)
}
}
return permalinksConfig, nil
}