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 }