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 }