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