mirror of
https://github.com/gohugoio/hugo.git
synced 2024-11-28 21:21:40 -05:00
a2f666b586
Fixes #12948
460 lines
13 KiB
Go
460 lines
13 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) {
|
|
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+(\[.+?\])?`)
|
|
|
|
type permalinkExpandError struct {
|
|
pattern string
|
|
err error
|
|
}
|
|
|
|
func (pee *permalinkExpandError) Error() string {
|
|
return fmt.Sprintf("error expanding %q: %s", pee.pattern, pee.err)
|
|
}
|
|
|
|
var 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
|
|
}
|