github.com/dominikszabo/hugo-ds-clean@v0.47.1/helpers/path.go (about) 1 // Copyright 2015 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/hugio" 28 "github.com/spf13/afero" 29 "golang.org/x/text/transform" 30 "golang.org/x/text/unicode/norm" 31 ) 32 33 var ( 34 // ErrThemeUndefined is returned when a theme has not be defined by the user. 35 ErrThemeUndefined = errors.New("no theme set") 36 ) 37 38 // filepathPathBridge is a bridge for common functionality in filepath vs path 39 type filepathPathBridge interface { 40 Base(in string) string 41 Clean(in string) string 42 Dir(in string) string 43 Ext(in string) string 44 Join(elem ...string) string 45 Separator() string 46 } 47 48 type filepathBridge struct { 49 } 50 51 func (filepathBridge) Base(in string) string { 52 return filepath.Base(in) 53 } 54 55 func (filepathBridge) Clean(in string) string { 56 return filepath.Clean(in) 57 } 58 59 func (filepathBridge) Dir(in string) string { 60 return filepath.Dir(in) 61 } 62 63 func (filepathBridge) Ext(in string) string { 64 return filepath.Ext(in) 65 } 66 67 func (filepathBridge) Join(elem ...string) string { 68 return filepath.Join(elem...) 69 } 70 71 func (filepathBridge) Separator() string { 72 return FilePathSeparator 73 } 74 75 var fpb filepathBridge 76 77 // MakePath takes a string with any characters and replace it 78 // so the string could be used in a path. 79 // It does so by creating a Unicode-sanitized string, with the spaces replaced, 80 // whilst preserving the original casing of the string. 81 // E.g. Social Media -> Social-Media 82 func (p *PathSpec) MakePath(s string) string { 83 return p.UnicodeSanitize(strings.Replace(strings.TrimSpace(s), " ", "-", -1)) 84 } 85 86 // MakePathSanitized creates a Unicode-sanitized string, with the spaces replaced 87 func (p *PathSpec) MakePathSanitized(s string) string { 88 if p.DisablePathToLower { 89 return p.MakePath(s) 90 } 91 return strings.ToLower(p.MakePath(s)) 92 } 93 94 // ToSlashTrimLeading is just a filepath.ToSlaas with an added / prefix trimmer. 95 func ToSlashTrimLeading(s string) string { 96 return strings.TrimPrefix(filepath.ToSlash(s), "/") 97 } 98 99 // MakeTitle converts the path given to a suitable title, trimming whitespace 100 // and replacing hyphens with whitespace. 101 func MakeTitle(inpath string) string { 102 return strings.Replace(strings.TrimSpace(inpath), "-", " ", -1) 103 } 104 105 // From https://golang.org/src/net/url/url.go 106 func ishex(c rune) bool { 107 switch { 108 case '0' <= c && c <= '9': 109 return true 110 case 'a' <= c && c <= 'f': 111 return true 112 case 'A' <= c && c <= 'F': 113 return true 114 } 115 return false 116 } 117 118 // UnicodeSanitize sanitizes string to be used in Hugo URL's, allowing only 119 // a predefined set of special Unicode characters. 120 // If RemovePathAccents configuration flag is enabled, Uniccode accents 121 // are also removed. 122 func (p *PathSpec) UnicodeSanitize(s string) string { 123 source := []rune(s) 124 target := make([]rune, 0, len(source)) 125 126 for i, r := range source { 127 if r == '%' && i+2 < len(source) && ishex(source[i+1]) && ishex(source[i+2]) { 128 target = append(target, r) 129 } else if unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsMark(r) || r == '.' || r == '/' || r == '\\' || r == '_' || r == '-' || r == '#' || r == '+' || r == '~' { 130 target = append(target, r) 131 } 132 } 133 134 var result string 135 136 if p.RemovePathAccents { 137 // remove accents - see https://blog.golang.org/normalization 138 t := transform.Chain(norm.NFD, transform.RemoveFunc(isMn), norm.NFC) 139 result, _, _ = transform.String(t, string(target)) 140 } else { 141 result = string(target) 142 } 143 144 return result 145 } 146 147 func isMn(r rune) bool { 148 return unicode.Is(unicode.Mn, r) // Mn: nonspacing marks 149 } 150 151 // ReplaceExtension takes a path and an extension, strips the old extension 152 // and returns the path with the new extension. 153 func ReplaceExtension(path string, newExt string) string { 154 f, _ := fileAndExt(path, fpb) 155 return f + "." + newExt 156 } 157 158 // GetFirstThemeDir gets the root directory of the first theme, if there is one. 159 // If there is no theme, returns the empty string. 160 func (p *PathSpec) GetFirstThemeDir() string { 161 if p.ThemeSet() { 162 return p.AbsPathify(filepath.Join(p.ThemesDir, p.Themes()[0])) 163 } 164 return "" 165 } 166 167 // GetThemesDir gets the absolute root theme dir path. 168 func (p *PathSpec) GetThemesDir() string { 169 if p.ThemeSet() { 170 return p.AbsPathify(p.ThemesDir) 171 } 172 return "" 173 } 174 175 // GetRelativeThemeDir gets the relative root directory of the current theme, if there is one. 176 // If there is no theme, returns the empty string. 177 func (p *PathSpec) GetRelativeThemeDir() string { 178 if p.ThemeSet() { 179 return strings.TrimPrefix(filepath.Join(p.ThemesDir, p.Themes()[0]), FilePathSeparator) 180 } 181 return "" 182 } 183 184 func makePathRelative(inPath string, possibleDirectories ...string) (string, error) { 185 186 for _, currentPath := range possibleDirectories { 187 if strings.HasPrefix(inPath, currentPath) { 188 return strings.TrimPrefix(inPath, currentPath), nil 189 } 190 } 191 return inPath, errors.New("Can't extract relative path, unknown prefix") 192 } 193 194 // Should be good enough for Hugo. 195 var isFileRe = regexp.MustCompile(`.*\..{1,6}$`) 196 197 // GetDottedRelativePath expects a relative path starting after the content directory. 198 // It returns a relative path with dots ("..") navigating up the path structure. 199 func GetDottedRelativePath(inPath string) string { 200 inPath = filepath.Clean(filepath.FromSlash(inPath)) 201 202 if inPath == "." { 203 return "./" 204 } 205 206 if !isFileRe.MatchString(inPath) && !strings.HasSuffix(inPath, FilePathSeparator) { 207 inPath += FilePathSeparator 208 } 209 210 if !strings.HasPrefix(inPath, FilePathSeparator) { 211 inPath = FilePathSeparator + inPath 212 } 213 214 dir, _ := filepath.Split(inPath) 215 216 sectionCount := strings.Count(dir, FilePathSeparator) 217 218 if sectionCount == 0 || dir == FilePathSeparator { 219 return "./" 220 } 221 222 var dottedPath string 223 224 for i := 1; i < sectionCount; i++ { 225 dottedPath += "../" 226 } 227 228 return dottedPath 229 } 230 231 // ExtNoDelimiter takes a path and returns the extension, excluding the delmiter, i.e. "md". 232 func ExtNoDelimiter(in string) string { 233 return strings.TrimPrefix(Ext(in), ".") 234 } 235 236 // Ext takes a path and returns the extension, including the delmiter, i.e. ".md". 237 func Ext(in string) string { 238 _, ext := fileAndExt(in, fpb) 239 return ext 240 } 241 242 // PathAndExt is the same as FileAndExt, but it uses the path package. 243 func PathAndExt(in string) (string, string) { 244 return fileAndExt(in, pb) 245 } 246 247 // FileAndExt takes a path and returns the file and extension separated, 248 // the extension including the delmiter, i.e. ".md". 249 func FileAndExt(in string) (string, string) { 250 return fileAndExt(in, fpb) 251 } 252 253 // Filename takes a path, strips out the extension, 254 // and returns the name of the file. 255 func Filename(in string) (name string) { 256 name, _ = fileAndExt(in, fpb) 257 return 258 } 259 260 // FileAndExt returns the filename and any extension of a file path as 261 // two separate strings. 262 // 263 // If the path, in, contains a directory name ending in a slash, 264 // then both name and ext will be empty strings. 265 // 266 // If the path, in, is either the current directory, the parent 267 // directory or the root directory, or an empty string, 268 // then both name and ext will be empty strings. 269 // 270 // If the path, in, represents the path of a file without an extension, 271 // then name will be the name of the file and ext will be an empty string. 272 // 273 // If the path, in, represents a filename with an extension, 274 // then name will be the filename minus any extension - including the dot 275 // and ext will contain the extension - minus the dot. 276 func fileAndExt(in string, b filepathPathBridge) (name string, ext string) { 277 ext = b.Ext(in) 278 base := b.Base(in) 279 280 return extractFilename(in, ext, base, b.Separator()), ext 281 } 282 283 func extractFilename(in, ext, base, pathSeparator string) (name string) { 284 285 // No file name cases. These are defined as: 286 // 1. any "in" path that ends in a pathSeparator 287 // 2. any "base" consisting of just an pathSeparator 288 // 3. any "base" consisting of just an empty string 289 // 4. any "base" consisting of just the current directory i.e. "." 290 // 5. any "base" consisting of just the parent directory i.e. ".." 291 if (strings.LastIndex(in, pathSeparator) == len(in)-1) || base == "" || base == "." || base == ".." || base == pathSeparator { 292 name = "" // there is NO filename 293 } else if ext != "" { // there was an Extension 294 // return the filename minus the extension (and the ".") 295 name = base[:strings.LastIndex(base, ".")] 296 } else { 297 // no extension case so just return base, which willi 298 // be the filename 299 name = base 300 } 301 return 302 303 } 304 305 // GetRelativePath returns the relative path of a given path. 306 func GetRelativePath(path, base string) (final string, err error) { 307 if filepath.IsAbs(path) && base == "" { 308 return "", errors.New("source: missing base directory") 309 } 310 name := filepath.Clean(path) 311 base = filepath.Clean(base) 312 313 name, err = filepath.Rel(base, name) 314 if err != nil { 315 return "", err 316 } 317 318 if strings.HasSuffix(filepath.FromSlash(path), FilePathSeparator) && !strings.HasSuffix(name, FilePathSeparator) { 319 name += FilePathSeparator 320 } 321 return name, nil 322 } 323 324 // PathPrep prepares the path using the uglify setting to create paths on 325 // either the form /section/name/index.html or /section/name.html. 326 func PathPrep(ugly bool, in string) string { 327 if ugly { 328 return Uglify(in) 329 } 330 return PrettifyPath(in) 331 } 332 333 // PrettifyPath is the same as PrettifyURLPath but for file paths. 334 // /section/name.html becomes /section/name/index.html 335 // /section/name/ becomes /section/name/index.html 336 // /section/name/index.html becomes /section/name/index.html 337 func PrettifyPath(in string) string { 338 return prettifyPath(in, fpb) 339 } 340 341 func prettifyPath(in string, b filepathPathBridge) string { 342 if filepath.Ext(in) == "" { 343 // /section/name/ -> /section/name/index.html 344 if len(in) < 2 { 345 return b.Separator() 346 } 347 return b.Join(in, "index.html") 348 } 349 name, ext := fileAndExt(in, b) 350 if name == "index" { 351 // /section/name/index.html -> /section/name/index.html 352 return b.Clean(in) 353 } 354 // /section/name.html -> /section/name/index.html 355 return b.Join(b.Dir(in), name, "index"+ext) 356 } 357 358 // ExtractRootPaths extracts the root paths from the supplied list of paths. 359 // The resulting root path will not contain any file separators, but there 360 // may be duplicates. 361 // So "/content/section/" becomes "content" 362 func ExtractRootPaths(paths []string) []string { 363 r := make([]string, len(paths)) 364 for i, p := range paths { 365 root := filepath.ToSlash(p) 366 sections := strings.Split(root, "/") 367 for _, section := range sections { 368 if section != "" { 369 root = section 370 break 371 } 372 } 373 r[i] = root 374 } 375 return r 376 377 } 378 379 // FindCWD returns the current working directory from where the Hugo 380 // executable is run. 381 func FindCWD() (string, error) { 382 serverFile, err := filepath.Abs(os.Args[0]) 383 384 if err != nil { 385 return "", fmt.Errorf("Can't get absolute path for executable: %v", err) 386 } 387 388 path := filepath.Dir(serverFile) 389 realFile, err := filepath.EvalSymlinks(serverFile) 390 391 if err != nil { 392 if _, err = os.Stat(serverFile + ".exe"); err == nil { 393 realFile = filepath.Clean(serverFile + ".exe") 394 } 395 } 396 397 if err == nil && realFile != serverFile { 398 path = filepath.Dir(realFile) 399 } 400 401 return path, nil 402 } 403 404 // SymbolicWalk is like filepath.Walk, but it supports the root being a 405 // symbolic link. It will still not follow symbolic links deeper down in 406 // the file structure. 407 func SymbolicWalk(fs afero.Fs, root string, walker filepath.WalkFunc) error { 408 409 // Sanity check 410 if root != "" && len(root) < 4 { 411 return errors.New("Path is too short") 412 } 413 414 // Handle the root first 415 fileInfo, realPath, err := getRealFileInfo(fs, root) 416 417 if err != nil { 418 return walker(root, nil, err) 419 } 420 421 if !fileInfo.IsDir() { 422 return fmt.Errorf("Cannot walk regular file %s", root) 423 } 424 425 if err := walker(realPath, fileInfo, err); err != nil && err != filepath.SkipDir { 426 return err 427 } 428 429 // Some of Hugo's filesystems represents an ordered root folder, i.e. project first, then theme folders. 430 // Make sure that order is preserved. afero.Walk will sort the directories down in the file tree, 431 // but we don't care about that. 432 rootContent, err := readDir(fs, root, false) 433 434 if err != nil { 435 return walker(root, nil, err) 436 } 437 438 for _, fi := range rootContent { 439 if err := afero.Walk(fs, filepath.Join(root, fi.Name()), walker); err != nil { 440 return err 441 } 442 } 443 444 return nil 445 446 } 447 448 func readDir(fs afero.Fs, dirname string, doSort bool) ([]os.FileInfo, error) { 449 f, err := fs.Open(dirname) 450 if err != nil { 451 return nil, err 452 } 453 list, err := f.Readdir(-1) 454 f.Close() 455 if err != nil { 456 return nil, err 457 } 458 if doSort { 459 sort.Slice(list, func(i, j int) bool { return list[i].Name() < list[j].Name() }) 460 } 461 return list, nil 462 } 463 464 func getRealFileInfo(fs afero.Fs, path string) (os.FileInfo, string, error) { 465 fileInfo, err := LstatIfPossible(fs, path) 466 realPath := path 467 468 if err != nil { 469 return nil, "", err 470 } 471 472 if fileInfo.Mode()&os.ModeSymlink == os.ModeSymlink { 473 link, err := filepath.EvalSymlinks(path) 474 if err != nil { 475 return nil, "", fmt.Errorf("Cannot read symbolic link '%s', error was: %s", path, err) 476 } 477 fileInfo, err = LstatIfPossible(fs, link) 478 if err != nil { 479 return nil, "", fmt.Errorf("Cannot stat '%s', error was: %s", link, err) 480 } 481 realPath = link 482 } 483 return fileInfo, realPath, nil 484 } 485 486 // GetRealPath returns the real file path for the given path, whether it is a 487 // symlink or not. 488 func GetRealPath(fs afero.Fs, path string) (string, error) { 489 _, realPath, err := getRealFileInfo(fs, path) 490 491 if err != nil { 492 return "", err 493 } 494 495 return realPath, nil 496 } 497 498 // LstatIfPossible can be used to call Lstat if possible, else Stat. 499 func LstatIfPossible(fs afero.Fs, path string) (os.FileInfo, error) { 500 if lstater, ok := fs.(afero.Lstater); ok { 501 fi, _, err := lstater.LstatIfPossible(path) 502 return fi, err 503 } 504 505 return fs.Stat(path) 506 } 507 508 // SafeWriteToDisk is the same as WriteToDisk 509 // but it also checks to see if file/directory already exists. 510 func SafeWriteToDisk(inpath string, r io.Reader, fs afero.Fs) (err error) { 511 return afero.SafeWriteReader(fs, inpath, r) 512 } 513 514 // WriteToDisk writes content to disk. 515 func WriteToDisk(inpath string, r io.Reader, fs afero.Fs) (err error) { 516 return afero.WriteReader(fs, inpath, r) 517 } 518 519 // OpenFileForWriting opens all the given filenames for writing. 520 func OpenFilesForWriting(fs afero.Fs, filenames ...string) (io.WriteCloser, error) { 521 var writeClosers []io.WriteCloser 522 for _, filename := range filenames { 523 f, err := OpenFileForWriting(fs, filename) 524 if err != nil { 525 for _, wc := range writeClosers { 526 wc.Close() 527 } 528 return nil, err 529 } 530 writeClosers = append(writeClosers, f) 531 } 532 533 return hugio.NewMultiWriteCloser(writeClosers...), nil 534 535 } 536 537 // OpenFileForWriting opens or creates the given file. If the target directory 538 // does not exist, it gets created. 539 func OpenFileForWriting(fs afero.Fs, filename string) (afero.File, error) { 540 filename = filepath.Clean(filename) 541 // Create will truncate if file already exists. 542 f, err := fs.Create(filename) 543 if err != nil { 544 if !os.IsNotExist(err) { 545 return nil, err 546 } 547 if err = fs.MkdirAll(filepath.Dir(filename), 0755); err != nil { 548 return nil, err 549 } 550 f, err = fs.Create(filename) 551 } 552 553 return f, err 554 } 555 556 // GetTempDir returns a temporary directory with the given sub path. 557 func GetTempDir(subPath string, fs afero.Fs) string { 558 return afero.GetTempDir(fs, subPath) 559 } 560 561 // DirExists checks if a path exists and is a directory. 562 func DirExists(path string, fs afero.Fs) (bool, error) { 563 return afero.DirExists(fs, path) 564 } 565 566 // IsDir checks if a given path is a directory. 567 func IsDir(path string, fs afero.Fs) (bool, error) { 568 return afero.IsDir(fs, path) 569 } 570 571 // IsEmpty checks if a given path is empty. 572 func IsEmpty(path string, fs afero.Fs) (bool, error) { 573 return afero.IsEmpty(fs, path) 574 } 575 576 // FileContains checks if a file contains a specified string. 577 func FileContains(filename string, subslice []byte, fs afero.Fs) (bool, error) { 578 return afero.FileContainsBytes(fs, filename, subslice) 579 } 580 581 // FileContainsAny checks if a file contains any of the specified strings. 582 func FileContainsAny(filename string, subslices [][]byte, fs afero.Fs) (bool, error) { 583 return afero.FileContainsAnyBytes(fs, filename, subslices) 584 } 585 586 // Exists checks if a file or directory exists. 587 func Exists(path string, fs afero.Fs) (bool, error) { 588 return afero.Exists(fs, path) 589 }