path.go (12823B)
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 helpers
15
16 import (
17 "errors"
18 "fmt"
19 "io"
20 "os"
21 "path/filepath"
22 "regexp"
23 "sort"
24 "strings"
25 "unicode"
26
27 "github.com/gohugoio/hugo/common/text"
28
29 "github.com/gohugoio/hugo/config"
30
31 "github.com/gohugoio/hugo/hugofs"
32
33 "github.com/gohugoio/hugo/common/hugio"
34 "github.com/spf13/afero"
35 )
36
37 // MakePath takes a string with any characters and replace it
38 // so the string could be used in a path.
39 // It does so by creating a Unicode-sanitized string, with the spaces replaced,
40 // whilst preserving the original casing of the string.
41 // E.g. Social Media -> Social-Media
42 func (p *PathSpec) MakePath(s string) string {
43 return p.UnicodeSanitize(s)
44 }
45
46 // MakePathsSanitized applies MakePathSanitized on every item in the slice
47 func (p *PathSpec) MakePathsSanitized(paths []string) {
48 for i, path := range paths {
49 paths[i] = p.MakePathSanitized(path)
50 }
51 }
52
53 // MakePathSanitized creates a Unicode-sanitized string, with the spaces replaced
54 func (p *PathSpec) MakePathSanitized(s string) string {
55 if p.DisablePathToLower {
56 return p.MakePath(s)
57 }
58 return strings.ToLower(p.MakePath(s))
59 }
60
61 // ToSlashTrimLeading is just a filepath.ToSlaas with an added / prefix trimmer.
62 func ToSlashTrimLeading(s string) string {
63 return strings.TrimPrefix(filepath.ToSlash(s), "/")
64 }
65
66 // MakeTitle converts the path given to a suitable title, trimming whitespace
67 // and replacing hyphens with whitespace.
68 func MakeTitle(inpath string) string {
69 return strings.Replace(strings.TrimSpace(inpath), "-", " ", -1)
70 }
71
72 // From https://golang.org/src/net/url/url.go
73 func ishex(c rune) bool {
74 switch {
75 case '0' <= c && c <= '9':
76 return true
77 case 'a' <= c && c <= 'f':
78 return true
79 case 'A' <= c && c <= 'F':
80 return true
81 }
82 return false
83 }
84
85 // UnicodeSanitize sanitizes string to be used in Hugo URL's, allowing only
86 // a predefined set of special Unicode characters.
87 // If RemovePathAccents configuration flag is enabled, Unicode accents
88 // are also removed.
89 // Hyphens in the original input are maintained.
90 // Spaces will be replaced with a single hyphen, and sequential replacement hyphens will be reduced to one.
91 func (p *PathSpec) UnicodeSanitize(s string) string {
92 if p.RemovePathAccents {
93 s = text.RemoveAccentsString(s)
94 }
95
96 source := []rune(s)
97 target := make([]rune, 0, len(source))
98 var (
99 prependHyphen bool
100 wasHyphen bool
101 )
102
103 for i, r := range source {
104 isAllowed := r == '.' || r == '/' || r == '\\' || r == '_' || r == '#' || r == '+' || r == '~' || r == '-'
105 isAllowed = isAllowed || unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsMark(r)
106 isAllowed = isAllowed || (r == '%' && i+2 < len(source) && ishex(source[i+1]) && ishex(source[i+2]))
107
108 if isAllowed {
109 // track explicit hyphen in input; no need to add a new hyphen if
110 // we just saw one.
111 wasHyphen = r == '-'
112
113 if prependHyphen {
114 // if currently have a hyphen, don't prepend an extra one
115 if !wasHyphen {
116 target = append(target, '-')
117 }
118 prependHyphen = false
119 }
120 target = append(target, r)
121 } else if len(target) > 0 && !wasHyphen && unicode.IsSpace(r) {
122 prependHyphen = true
123 }
124 }
125
126 return string(target)
127 }
128
129 func makePathRelative(inPath string, possibleDirectories ...string) (string, error) {
130 for _, currentPath := range possibleDirectories {
131 if strings.HasPrefix(inPath, currentPath) {
132 return strings.TrimPrefix(inPath, currentPath), nil
133 }
134 }
135 return inPath, errors.New("can't extract relative path, unknown prefix")
136 }
137
138 // Should be good enough for Hugo.
139 var isFileRe = regexp.MustCompile(`.*\..{1,6}$`)
140
141 // GetDottedRelativePath expects a relative path starting after the content directory.
142 // It returns a relative path with dots ("..") navigating up the path structure.
143 func GetDottedRelativePath(inPath string) string {
144 inPath = filepath.Clean(filepath.FromSlash(inPath))
145
146 if inPath == "." {
147 return "./"
148 }
149
150 if !isFileRe.MatchString(inPath) && !strings.HasSuffix(inPath, FilePathSeparator) {
151 inPath += FilePathSeparator
152 }
153
154 if !strings.HasPrefix(inPath, FilePathSeparator) {
155 inPath = FilePathSeparator + inPath
156 }
157
158 dir, _ := filepath.Split(inPath)
159
160 sectionCount := strings.Count(dir, FilePathSeparator)
161
162 if sectionCount == 0 || dir == FilePathSeparator {
163 return "./"
164 }
165
166 var dottedPath string
167
168 for i := 1; i < sectionCount; i++ {
169 dottedPath += "../"
170 }
171
172 return dottedPath
173 }
174
175 type NamedSlice struct {
176 Name string
177 Slice []string
178 }
179
180 func (n NamedSlice) String() string {
181 if len(n.Slice) == 0 {
182 return n.Name
183 }
184 return fmt.Sprintf("%s%s{%s}", n.Name, FilePathSeparator, strings.Join(n.Slice, ","))
185 }
186
187 func ExtractAndGroupRootPaths(paths []string) []NamedSlice {
188 if len(paths) == 0 {
189 return nil
190 }
191
192 pathsCopy := make([]string, len(paths))
193 hadSlashPrefix := strings.HasPrefix(paths[0], FilePathSeparator)
194
195 for i, p := range paths {
196 pathsCopy[i] = strings.Trim(filepath.ToSlash(p), "/")
197 }
198
199 sort.Strings(pathsCopy)
200
201 pathsParts := make([][]string, len(pathsCopy))
202
203 for i, p := range pathsCopy {
204 pathsParts[i] = strings.Split(p, "/")
205 }
206
207 var groups [][]string
208
209 for i, p1 := range pathsParts {
210 c1 := -1
211
212 for j, p2 := range pathsParts {
213 if i == j {
214 continue
215 }
216
217 c2 := -1
218
219 for i, v := range p1 {
220 if i >= len(p2) {
221 break
222 }
223 if v != p2[i] {
224 break
225 }
226
227 c2 = i
228 }
229
230 if c1 == -1 || (c2 != -1 && c2 < c1) {
231 c1 = c2
232 }
233 }
234
235 if c1 != -1 {
236 groups = append(groups, p1[:c1+1])
237 } else {
238 groups = append(groups, p1)
239 }
240 }
241
242 groupsStr := make([]string, len(groups))
243 for i, g := range groups {
244 groupsStr[i] = strings.Join(g, "/")
245 }
246
247 groupsStr = UniqueStringsSorted(groupsStr)
248
249 var result []NamedSlice
250
251 for _, g := range groupsStr {
252 name := filepath.FromSlash(g)
253 if hadSlashPrefix {
254 name = FilePathSeparator + name
255 }
256 ns := NamedSlice{Name: name}
257 for _, p := range pathsCopy {
258 if !strings.HasPrefix(p, g) {
259 continue
260 }
261
262 p = strings.TrimPrefix(p, g)
263 if p != "" {
264 ns.Slice = append(ns.Slice, p)
265 }
266 }
267
268 ns.Slice = UniqueStrings(ExtractRootPaths(ns.Slice))
269
270 result = append(result, ns)
271 }
272
273 return result
274 }
275
276 // ExtractRootPaths extracts the root paths from the supplied list of paths.
277 // The resulting root path will not contain any file separators, but there
278 // may be duplicates.
279 // So "/content/section/" becomes "content"
280 func ExtractRootPaths(paths []string) []string {
281 r := make([]string, len(paths))
282 for i, p := range paths {
283 root := filepath.ToSlash(p)
284 sections := strings.Split(root, "/")
285 for _, section := range sections {
286 if section != "" {
287 root = section
288 break
289 }
290 }
291 r[i] = root
292 }
293 return r
294 }
295
296 // FindCWD returns the current working directory from where the Hugo
297 // executable is run.
298 func FindCWD() (string, error) {
299 serverFile, err := filepath.Abs(os.Args[0])
300 if err != nil {
301 return "", fmt.Errorf("can't get absolute path for executable: %v", err)
302 }
303
304 path := filepath.Dir(serverFile)
305 realFile, err := filepath.EvalSymlinks(serverFile)
306 if err != nil {
307 if _, err = os.Stat(serverFile + ".exe"); err == nil {
308 realFile = filepath.Clean(serverFile + ".exe")
309 }
310 }
311
312 if err == nil && realFile != serverFile {
313 path = filepath.Dir(realFile)
314 }
315
316 return path, nil
317 }
318
319 // SymbolicWalk is like filepath.Walk, but it follows symbolic links.
320 func SymbolicWalk(fs afero.Fs, root string, walker hugofs.WalkFunc) error {
321 if _, isOs := fs.(*afero.OsFs); isOs {
322 // Mainly to track symlinks.
323 fs = hugofs.NewBaseFileDecorator(fs)
324 }
325
326 w := hugofs.NewWalkway(hugofs.WalkwayConfig{
327 Fs: fs,
328 Root: root,
329 WalkFn: walker,
330 })
331
332 return w.Walk()
333 }
334
335 // LstatIfPossible can be used to call Lstat if possible, else Stat.
336 func LstatIfPossible(fs afero.Fs, path string) (os.FileInfo, error) {
337 if lstater, ok := fs.(afero.Lstater); ok {
338 fi, _, err := lstater.LstatIfPossible(path)
339 return fi, err
340 }
341
342 return fs.Stat(path)
343 }
344
345 // SafeWriteToDisk is the same as WriteToDisk
346 // but it also checks to see if file/directory already exists.
347 func SafeWriteToDisk(inpath string, r io.Reader, fs afero.Fs) (err error) {
348 return afero.SafeWriteReader(fs, inpath, r)
349 }
350
351 // WriteToDisk writes content to disk.
352 func WriteToDisk(inpath string, r io.Reader, fs afero.Fs) (err error) {
353 return afero.WriteReader(fs, inpath, r)
354 }
355
356 // OpenFilesForWriting opens all the given filenames for writing.
357 func OpenFilesForWriting(fs afero.Fs, filenames ...string) (io.WriteCloser, error) {
358 var writeClosers []io.WriteCloser
359 for _, filename := range filenames {
360 f, err := OpenFileForWriting(fs, filename)
361 if err != nil {
362 for _, wc := range writeClosers {
363 wc.Close()
364 }
365 return nil, err
366 }
367 writeClosers = append(writeClosers, f)
368 }
369
370 return hugio.NewMultiWriteCloser(writeClosers...), nil
371 }
372
373 // OpenFileForWriting opens or creates the given file. If the target directory
374 // does not exist, it gets created.
375 func OpenFileForWriting(fs afero.Fs, filename string) (afero.File, error) {
376 filename = filepath.Clean(filename)
377 // Create will truncate if file already exists.
378 // os.Create will create any new files with mode 0666 (before umask).
379 f, err := fs.Create(filename)
380 if err != nil {
381 if !os.IsNotExist(err) {
382 return nil, err
383 }
384 if err = fs.MkdirAll(filepath.Dir(filename), 0777); err != nil { // before umask
385 return nil, err
386 }
387 f, err = fs.Create(filename)
388 }
389
390 return f, err
391 }
392
393 // GetCacheDir returns a cache dir from the given filesystem and config.
394 // The dir will be created if it does not exist.
395 func GetCacheDir(fs afero.Fs, cfg config.Provider) (string, error) {
396 cacheDir := getCacheDir(cfg)
397 if cacheDir != "" {
398 exists, err := DirExists(cacheDir, fs)
399 if err != nil {
400 return "", err
401 }
402 if !exists {
403 err := fs.MkdirAll(cacheDir, 0777) // Before umask
404 if err != nil {
405 return "", fmt.Errorf("failed to create cache dir: %w", err)
406 }
407 }
408 return cacheDir, nil
409 }
410
411 // Fall back to a cache in /tmp.
412 return GetTempDir("hugo_cache", fs), nil
413 }
414
415 func getCacheDir(cfg config.Provider) string {
416 // Always use the cacheDir config if set.
417 cacheDir := cfg.GetString("cacheDir")
418 if len(cacheDir) > 1 {
419 return addTrailingFileSeparator(cacheDir)
420 }
421
422 // See Issue #8714.
423 // Turns out that Cloudflare also sets NETLIFY=true in its build environment,
424 // but all of these 3 should not give any false positives.
425 if os.Getenv("NETLIFY") == "true" && os.Getenv("PULL_REQUEST") != "" && os.Getenv("DEPLOY_PRIME_URL") != "" {
426 // Netlify's cache behaviour is not documented, the currently best example
427 // is this project:
428 // https://github.com/philhawksworth/content-shards/blob/master/gulpfile.js
429 return "/opt/build/cache/hugo_cache/"
430 }
431
432 // This will fall back to an hugo_cache folder in the tmp dir, which should work fine for most CI
433 // providers. See this for a working CircleCI setup:
434 // https://github.com/bep/hugo-sass-test/blob/6c3960a8f4b90e8938228688bc49bdcdd6b2d99e/.circleci/config.yml
435 // If not, they can set the HUGO_CACHEDIR environment variable or cacheDir config key.
436 return ""
437 }
438
439 func addTrailingFileSeparator(s string) string {
440 if !strings.HasSuffix(s, FilePathSeparator) {
441 s = s + FilePathSeparator
442 }
443 return s
444 }
445
446 // GetTempDir returns a temporary directory with the given sub path.
447 func GetTempDir(subPath string, fs afero.Fs) string {
448 return afero.GetTempDir(fs, subPath)
449 }
450
451 // DirExists checks if a path exists and is a directory.
452 func DirExists(path string, fs afero.Fs) (bool, error) {
453 return afero.DirExists(fs, path)
454 }
455
456 // IsDir checks if a given path is a directory.
457 func IsDir(path string, fs afero.Fs) (bool, error) {
458 return afero.IsDir(fs, path)
459 }
460
461 // IsEmpty checks if a given path is empty, meaning it doesn't contain any regular files.
462 func IsEmpty(path string, fs afero.Fs) (bool, error) {
463 var hasFile bool
464 err := afero.Walk(fs, path, func(path string, info os.FileInfo, err error) error {
465 if info.IsDir() {
466 return nil
467 }
468 hasFile = true
469 return filepath.SkipDir
470 })
471 return !hasFile, err
472 }
473
474 // Exists checks if a file or directory exists.
475 func Exists(path string, fs afero.Fs) (bool, error) {
476 return afero.Exists(fs, path)
477 }
478
479 // AddTrailingSlash adds a trailing Unix styled slash (/) if not already
480 // there.
481 func AddTrailingSlash(path string) string {
482 if !strings.HasSuffix(path, "/") {
483 path += "/"
484 }
485 return path
486 }