github.com/lolorenzo777/zazzy@v0.4.3/zazzy.go (about) 1 package main 2 3 import ( 4 "bufio" 5 "bytes" 6 "errors" 7 "fmt" 8 "io" 9 "io/ioutil" 10 "log" 11 "os" 12 "os/exec" 13 "path/filepath" 14 "sort" 15 "strings" 16 "text/template" 17 "time" 18 19 "github.com/gobwas/glob" 20 "github.com/lolorenzo777/loadfavicon/getfavicon" 21 "github.com/russross/blackfriday/v2" 22 "gopkg.in/yaml.v3" 23 ) 24 25 const ( 26 ZSDIR = ".zazzy" 27 DFTPUBDIR = ".pub" 28 ) 29 30 var PUBDIR string = DFTPUBDIR 31 32 type Vars map[string]string 33 34 // renameExt renames extension (if any) from oldext to newext 35 // If oldext is an empty string - extension is extracted automatically. 36 // If path has no extension - new extension is appended 37 func renameExt(path, oldext, newext string) string { 38 if oldext == "" { 39 oldext = filepath.Ext(path) 40 } 41 if oldext == "" || strings.HasSuffix(path, oldext) { 42 return strings.TrimSuffix(path, oldext) + newext 43 } else { 44 return path 45 } 46 } 47 48 // globals returns list of global OS environment variables that start 49 // with ZS_ prefix as Vars, so the values can be used inside templates 50 func globals() Vars { 51 vars := Vars{} 52 for _, e := range os.Environ() { 53 pair := strings.Split(e, "=") 54 if strings.HasPrefix(pair[0], "ZS_") { 55 vars[strings.ToLower(pair[0][3:])] = pair[1] 56 } 57 } 58 59 // special environment variable 60 if len(vars["pubdir"]) != 0 { 61 PUBDIR = vars["pubdir"] 62 } 63 if len(vars["favicondir"]) == 0 { 64 vars["favicondir"] = "/img/favicons" 65 } 66 67 return vars 68 } 69 70 // load .zazzy/.ignore file with list of files and directories to be ignored during the process 71 // each entry must be formatted as a glob pattern https://github.com/gobwas/glob 72 // return an array of trimed pattern of files to ignore 73 func loadIgnore() (lst []string) { 74 f, err := os.Open(filepath.Join(ZSDIR, ".ignore")) 75 if err != nil { 76 // .ignore file is not mandatory 77 return nil 78 } 79 defer f.Close() 80 81 // read the file line by line using scanner 82 scanner := bufio.NewScanner(f) 83 84 for scanner.Scan() { 85 entry := strings.Trim(scanner.Text(), " ") 86 if len(entry)>0 && entry[:1] != "#" { 87 if _, err := glob.Compile(entry); err != nil { 88 log.Println(err) 89 } else { 90 lst = append(lst, entry) 91 } 92 } 93 } 94 95 // ensure PUBDIR is always ignored 96 if filepath.Base(PUBDIR)[0] != '.' && !strings.HasPrefix(PUBDIR, ".") { 97 pubdir := strings.TrimRight(PUBDIR, "/") + "**" 98 lst = append(lst, pubdir) 99 } 100 return lst 101 } 102 103 var gSitemapWarning bool 104 105 // appendSitemap generate an entry in the sitemap.txt file 106 // according to paramaters: ZS_SITEMAPTXT must be true, and 107 // the "sitemap: true" is in the YAML file header 108 func appendSitemap(path string, vars Vars) { 109 if strings.ToLower(vars["sitemaptype"]) != "txt" { 110 return 111 } 112 113 if strings.ToLower(vars["sitemap"]) != "true" { 114 return 115 } 116 117 if len(vars["hosturl"]) == 0 && !gSitemapWarning { 118 gSitemapWarning = true 119 fmt.Println("Warning: generating sitemap without hosturl.") 120 } 121 122 sitemapentry := filepath.Join(vars["hosturl"], vars["url"]) 123 124 file, err := os.OpenFile(filepath.Join(PUBDIR, "sitemap.txt"), os.O_RDWR|os.O_CREATE, 0755) 125 if err != nil { 126 log.Println(err) 127 return 128 } 129 defer file.Close() 130 scanner := bufio.NewScanner(file) 131 for scanner.Scan() { 132 // do not add twice the same URL 133 if strings.ToLower(strings.Trim(scanner.Text(), " ")) == sitemapentry { 134 return 135 } 136 } 137 if err := scanner.Err(); err != nil { 138 log.Println(err) 139 return 140 } 141 if _, err := file.WriteString( sitemapentry +"\n"); err != nil { 142 log.Println(err) 143 return 144 } 145 } 146 147 // run executes a command or a script. Vars define the command environment, 148 // each zs var is converted into OS environemnt variable with ZS_ prefix 149 // prepended. Additional variable $ZS contains path to the zs binary. Command 150 // stderr is printed to zs stderr, command output is returned as a string. 151 func run(vars Vars, cmd string, args ...string) (string, error) { 152 // external commande (plugin) 153 var errbuf, outbuf bytes.Buffer 154 c := exec.Command(cmd, args...) 155 env := []string{"ZS=" + os.Args[0], "ZS_OUTDIR=" + PUBDIR} 156 env = append(env, os.Environ()...) 157 for k, v := range vars { 158 env = append(env, "ZS_"+strings.ToUpper(k)+"="+v) 159 } 160 c.Env = env 161 c.Stdout = &outbuf 162 c.Stderr = &errbuf 163 164 err := c.Run() 165 166 if errbuf.Len() > 0 { 167 log.Println("ERROR:", errbuf.String()) 168 } 169 if err != nil { 170 return "", err 171 } 172 return outbuf.String(), nil 173 } 174 175 // getDownloadedFavicon get favicon URL of the downloaded Favicon, and download it 176 // if it doesn't exist on the local directory. 177 func getDownloadedFavicon(website string) (url string, err error) { 178 179 vars := globals() 180 faviconCachePath := filepath.Join(PUBDIR, vars["favicondir"]) 181 faviconSlugifiedWebsite := getfavicon.SlugHost(website) 182 183 // look if favicon(s) has already been downloaded 184 cache, err := filepath.Glob(filepath.Join(faviconCachePath, faviconSlugifiedWebsite) + "+*.*") 185 if len(cache) == 0 && err == nil{ 186 // Connect to the website and download the best favicon 187 favicons, err := getfavicon.Download(website, faviconCachePath, true) 188 if len(favicons) == 0 { 189 log.Println(err) 190 return "", err 191 } 192 url = filepath.Join("/", vars["favicondir"], favicons[0].DiskFileName) 193 } else { 194 url = cache[0] 195 if url[:len(PUBDIR)] != PUBDIR { 196 panic("getDownloadedFavicon") 197 } 198 url = url[len(PUBDIR):] 199 } 200 201 return url, err 202 } 203 204 // renderFavicon donwload th favicon of a website given in paramaters 205 // and generate html to render thefavicon image. 206 func renderFavicon(vars Vars, args ...string) (string, error){ 207 if len(args) != 1 { 208 log.Println("favicon placeholder requires a website in parameter. nothing rendered") 209 return "", nil 210 } 211 212 faviconURL, err := getDownloadedFavicon(args[0]) 213 if len(faviconURL) > 0 && err == nil { 214 return "<img src=\"" + faviconURL +"\" alt=\"icon\" class=\"favicon\" role=\"img\">", nil 215 } 216 return "", err 217 } 218 219 // renderlist generate an HTML string for every files in the pattern 220 // passed in arg[0]. The string if rendered according to the itemlayout.html file. 221 // Than all strings are concatenated and ordered accordng to filenames in the pattern 222 func renderlist(vars Vars, args ...string) (string, error){ 223 // get the pattern of files to scan and list 224 if len(args) != 1 { 225 log.Println("renderlist placeholder requires pattern in parameter. nothing rendered.") 226 return "", nil 227 } 228 filelistpattern := args[0] 229 230 // check the pattern and get lisy of corresponding files 231 matchingfiles, err := filepath.Glob(filelistpattern) 232 if err != nil { 233 return "", errors.New("bad pattern") 234 } 235 if len(matchingfiles) == 0 { 236 fmt.Println("renderlist: no files corresponds to this pattern. The list is empty.", err) 237 return "", errors.New("bad pattern") 238 } 239 sort.Sort(sort.Reverse(sort.StringSlice(matchingfiles))) 240 // get list of files to ignore 241 ignorelist := loadIgnore() 242 243 // get the layout for items 244 if _, ok := vars["itemlayout"]; !ok { 245 vars["itemlayout"] = filepath.Join(ZSDIR, "itemlayout.html") 246 } 247 _, itemlayout, err := getVars(vars["itemlayout"], vars) 248 if err != nil { 249 fmt.Println("unable to proceed item layout file:", err) 250 } 251 252 // scan all existing files, and process as a list item 253 result := "" 254 for _, path := range matchingfiles { 255 // ignore hidden files and directories 256 if filepath.Base(path)[0] == '.' || strings.HasPrefix(path, ".") { 257 continue 258 } 259 260 // ignore files and directory listed in the .zazzy/.ignore file 261 for _, ignoreentry := range ignorelist { 262 g, _ := glob.Compile(ignoreentry) 263 if g.Match(path) { 264 fmt.Printf("renderlist item ignored: %q", path) 265 continue 266 } 267 } 268 269 // inform user about fs errors, but continue iteration 270 info, err := os.Stat(path) 271 if err != nil { 272 fmt.Println("renderlist item error:", err) 273 continue 274 } 275 276 if info.IsDir() { 277 continue 278 } else { 279 log.Println("renderlist item:", path) 280 // load file's vars 281 vitem, _, err := getVars(path, vars) 282 if err != nil { 283 fmt.Println("renderlist item error:", err) 284 return "", err 285 } 286 vitem["file"] = path 287 vitem["url"] = path[:len(path)-len(filepath.Ext(path))] + ".html" 288 vitem["output"] = filepath.Join(PUBDIR, vitem["url"]) 289 item, err := render(itemlayout, vitem, 1) 290 if err != nil { 291 return "", err 292 } 293 result += item 294 } 295 } 296 return result, nil 297 } 298 299 300 // getVars returns list of variables defined in a text file and actual file 301 // content following the variables declaration. Header is separated from 302 // content by an empty line. Header can be either YAML or JSON. 303 // If no empty newline is found - file is treated as content-only. 304 func getVars(path string, globals Vars) (Vars, string, error) { 305 b, err := ioutil.ReadFile(path) 306 if err != nil { 307 return nil, "", err 308 } 309 s := string(b) 310 311 // Pick some default values for content-dependent variables 312 v := Vars{} 313 title := strings.Replace(strings.Replace(path, "_", " ", -1), "-", " ", -1) 314 v["title"] = strings.ToTitle(title) 315 v["description"] = "" 316 v["file"] = path 317 v["url"] = path[:len(path)-len(filepath.Ext(path))] + ".html" 318 v["output"] = filepath.Join(PUBDIR, v["url"]) 319 320 // Override default values with globals 321 for name, value := range globals { 322 v[name] = value 323 } 324 325 // Add layout if none is specified 326 if _, ok := v["layout"]; !ok { 327 v["layout"] = "layout.html" 328 } 329 330 delim := "\n---\n" 331 if sep := strings.Index(s, delim); sep == -1 { 332 return v, s, nil 333 } else { 334 header := s[:sep] 335 body := s[sep+len(delim):] 336 337 vars := Vars{} 338 if err := yaml.Unmarshal([]byte(header), &vars); err != nil { 339 fmt.Println("ERROR: failed to parse header", err) 340 return nil, "", err 341 } else { 342 // Override default values + globals with the ones defines in the file 343 for key, value := range vars { 344 v[key] = value 345 } 346 } 347 v["url"] = strings.TrimLeft(v["url"], "./") 348 //if strings.HasPrefix(v["url"], "./") { 349 // v["url"] = v["url"][2:] 350 //} 351 return v, body, nil 352 } 353 } 354 355 // Render expanding zs plugins and variables, and process special command 356 func render(s string, vars Vars, deep int) (string, error) { 357 delim_open := "{{" 358 delim_close := "}}" 359 360 out := &bytes.Buffer{} 361 for { 362 if from := strings.Index(s, delim_open); from == -1 { 363 out.WriteString(s) 364 return out.String(), nil 365 } else { 366 if to := strings.Index(s, delim_close); to == -1 { 367 return "", fmt.Errorf("close delim not found") 368 } else { 369 out.WriteString(s[:from]) 370 cmd := s[from+len(delim_open) : to] 371 s = s[to+len(delim_close):] 372 m := strings.Fields(cmd) 373 // proceed with special commands 374 switch { 375 case m[0] == "renderlist": 376 if res, err := renderlist(vars, m[1:]...); err == nil { 377 out.WriteString(res) 378 } else { 379 fmt.Println(err) 380 } 381 continue 382 case m[0] == "favicon" : 383 if res, err := renderFavicon(vars, m[1:]...); err == nil { 384 out.WriteString(res) 385 } else { 386 fmt.Println(err) 387 } 388 continue 389 case filepath.Ext(m[0]) == ".html" || filepath.Ext(m[0]) == ".md": 390 // proceed partials (.html or md) 391 if b, err := ioutil.ReadFile(filepath.Join(ZSDIR, m[0])); err == nil { 392 // make it recursive 393 if deep > 10 { 394 return string(b), nil 395 } 396 if res, err := render(string(b), vars, deep+1); err == nil { 397 out.WriteString(res) 398 } else { 399 fmt.Println(err) 400 } 401 continue 402 } 403 fallthrough 404 case len(m) == 1 : 405 // variable 406 if v, ok := vars[m[0]]; ok { 407 out.WriteString(v) 408 continue 409 } 410 } 411 412 // sz pluggins 413 if res, err := run(vars, m[0], m[1:]...); err == nil { 414 out.WriteString(res) 415 } else { 416 fmt.Println(err) 417 } 418 } 419 } 420 } 421 } 422 423 // Renders markdown with the given layout into html expanding all the macros 424 func buildMarkdown(path string, w io.Writer, vars Vars) error { 425 v, body, err := getVars(path, vars) 426 if err != nil { 427 return err 428 } 429 content, err := render(body, v, 1) 430 if err != nil { 431 return err 432 } 433 v["content"] = string(blackfriday.Run([]byte(content))) 434 if w == nil { 435 out, err := os.Create(filepath.Join(PUBDIR, renameExt(path, "", ".html"))) 436 if err != nil { 437 return err 438 } 439 defer out.Close() 440 w = out 441 } 442 appendSitemap(path, v) 443 444 // process layout only if it exists 445 layoutfile := filepath.Join(ZSDIR, v["layout"]) 446 _, errlayout := os.Stat(layoutfile) 447 if errors.Is(errlayout, os.ErrNotExist) { 448 _, err = io.WriteString(w, v["content"]) 449 return err 450 } else if errlayout != nil { 451 return errlayout 452 } 453 454 return buildHTML(filepath.Join(ZSDIR, v["layout"]), w, v) 455 } 456 457 // Renders text file expanding all variable macros inside it 458 func buildHTML(path string, w io.Writer, vars Vars) error { 459 v, body, err := getVars(path, vars) 460 if err != nil { 461 return err 462 } 463 if body, err = render(body, v, 1); err != nil { 464 return err 465 } 466 tmpl, err := template.New("").Delims("<%", "%>").Parse(body) 467 if err != nil { 468 return err 469 } 470 if w == nil { 471 f, err := os.Create(filepath.Join(PUBDIR, path)) 472 if err != nil { 473 return err 474 } 475 defer f.Close() 476 w = f 477 } 478 appendSitemap(path, v) 479 480 return tmpl.Execute(w, vars) 481 } 482 483 // Copies file as is from path to writer 484 func buildRaw(path string, w io.Writer) error { 485 in, err := os.Open(path) 486 if err != nil { 487 return err 488 } 489 defer in.Close() 490 if w == nil { 491 if out, err := os.Create(filepath.Join(PUBDIR, path)); err != nil { 492 return err 493 } else { 494 defer out.Close() 495 w = out 496 } 497 } 498 _, err = io.Copy(w, in) 499 return err 500 } 501 502 func build(path string, w io.Writer, vars Vars) error { 503 ext := filepath.Ext(path) 504 var err error 505 if ext == ".md" || ext == ".mkd" { 506 err = buildMarkdown(path, w, vars) 507 } else if ext == ".html" || ext == ".xml" { 508 err = buildHTML(path, w, vars) 509 } else { 510 err = buildRaw(path, w) 511 } 512 if err != nil { 513 log.Println(err) 514 } 515 return err 516 } 517 518 func buildAll(watch bool) { 519 lastModified := time.Unix(0, 0) 520 modified := false 521 522 vars := globals() 523 ignorelist := loadIgnore() 524 // clear sitemap if any 525 os.Remove(filepath.Join(PUBDIR, "sitemap.txt")) 526 527 for { 528 os.Mkdir(PUBDIR, 0755) 529 filepath.Walk(".", func(path string, info os.FileInfo, err error) error { 530 // ignore hidden files and directories 531 if filepath.Base(path)[0] == '.' || strings.HasPrefix(path, ".") { 532 return nil 533 } 534 535 // ignore files and directory listed in the .zazzy/.ignore file 536 for _, ignoreentry := range ignorelist { 537 g, _ := glob.Compile(ignoreentry) 538 if g.Match(path) { 539 return nil 540 } 541 } 542 543 // inform user about fs walk errors, but continue iteration 544 if err != nil { 545 fmt.Println("buildAll error:", err) 546 return nil 547 } 548 549 if info.IsDir() { 550 os.Mkdir(filepath.Join(PUBDIR, path), 0755) 551 return nil 552 } else if info.ModTime().After(lastModified) { 553 if !modified { 554 // First file in this build cycle is about to be modified 555 run(vars, "buildAll prehook") 556 modified = true 557 } 558 log.Println("build:", path) 559 return build(path, nil, vars) 560 } 561 return nil 562 }) 563 if modified { 564 // At least one file in this build cycle has been modified 565 run(vars, "buildAll posthook") 566 modified = false 567 } 568 if !watch { 569 break 570 } 571 lastModified = time.Now() 572 time.Sleep(1 * time.Second) 573 } 574 } 575 576 func generateFile(path string, templatetext string, data any) (err error) { 577 tmp, _ := template.New("template").Parse(templatetext) 578 flayout, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 579 if err != nil { 580 fmt.Println(err) 581 return err 582 } 583 defer flayout.Close() 584 if err = tmp.Execute(flayout, data); err != nil { 585 fmt.Println(err) 586 } 587 return err 588 } 589 590 // generateNewWebsite create basic files in the current directory for a new website 591 // with a basic layout 592 // 593 // Parameters 594 // 595 // githubpages: 596 // vscode: create build & watch tasks 597 // 598 func generateNewWebsite(title string, hosturl string, vscode bool, githubpages bool, sitemap bool) { 599 hosturl = strings.ToLower(strings.Trim(hosturl, " ")) 600 // .zazzy 601 err := os.Mkdir(".zazzy", 0755) 602 if err != nil && os.IsExist(err) { 603 log.Println(".zazzy directory already exists. init process stops.") 604 return 605 } 606 os.Mkdir("css", 0755) 607 os.Mkdir("img", 0755) 608 os.Mkdir("js", 0755) 609 610 type TWebsite struct { 611 Title string 612 Url string 613 Description string 614 Export string 615 Sitemap string 616 } 617 website := TWebsite{ 618 Title: title, 619 Url: hosturl, 620 Sitemap: "false", 621 } 622 // fulfill the export variable 623 if githubpages { 624 website.Export = "rm -r docs; ZS_PUBDIR=docs " 625 } 626 if sitemap { 627 website.Export += " ZS_SITEMAPTYPE=txt" 628 website.Sitemap = "true" 629 } 630 website.Export += " ZS_HOSTURL=" + hosturl 631 website.Export += " " 632 633 // tasks.json 634 tasksTemplate := `{ 635 "version": "2.0.0", 636 "tasks": [ 637 { 638 "label": "build", 639 "type": "shell", 640 "command": "{{ .Export }}zazzy build" 641 }, 642 { 643 "label": "watch", 644 "type": "shell", 645 "command": "{{ .Export }}zazzy watch" 646 } 647 ] 648 }` 649 if vscode { 650 os.Mkdir(".vscode", 0755) 651 generateFile(".vscode/tasks.json", tasksTemplate, website) 652 } 653 654 // index.md 655 indexTemplate := `title: {{ .Title }} 656 url: {{ .Url }} 657 sitemap: {{ .Sitemap }} 658 --- 659 660 # Home {{ .Title }} 661 ` 662 generateFile("index.md", indexTemplate, website) 663 664 // layout.html 665 layoutTemplate := `<!DOCTYPE html> 666 <html lang="en"> 667 <head> 668 <title>{{ .Title }}</title> 669 <meta name="title" content="{{ .Title }}"> 670 <link rel="canonical" href="{{ .Url }}"> 671 <meta charset="utf-8"> 672 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 673 674 <!--page-description--> 675 <meta name="description" content="{{ .Description }}"> 676 677 <!--favicon--> 678 <link rel="shortcut icon" href="/favicon.ico"> 679 </head> 680 <body> 681 {{"{{content}}"}} 682 </body> 683 </html> 684 ` 685 generateFile(".zazzy/layout.html", layoutTemplate, website) 686 687 if githubpages { 688 // .ignore 689 ignoreTemplate := `# files to ignore 690 readme.md 691 ` 692 generateFile(".zazzy/.ignore", ignoreTemplate, website) 693 } 694 fmt.Println("zazzy website generated") 695 } 696 697 func init() { 698 // prepend .zazzy to $PATH, so plugins will be found before OS commands 699 p := os.Getenv("PATH") 700 p = ZSDIR + ":" + p 701 os.Setenv("PATH", p) 702 } 703 704 func main() { 705 if len(os.Args) == 1 { 706 fmt.Println(os.Args[0], "<command> [args]") 707 return 708 } 709 cmd := os.Args[1] 710 args := os.Args[2:] 711 switch cmd { 712 case "build": 713 if len(args) == 0 { 714 buildAll(false) 715 } else if len(args) == 1 { 716 if err := build(args[0], os.Stdout, globals()); err != nil { 717 fmt.Println("ERROR: " + err.Error()) 718 } 719 } else { 720 fmt.Println("ERROR: too many arguments") 721 } 722 case "watch": 723 buildAll(true) 724 case "init": { 725 if len(args) <= 2 { 726 fmt.Println("init: website title and host url expected") 727 } else { 728 fvscode := false 729 fgithubpages := false 730 fsitemap := false 731 for _, v := range(args[2:]) { 732 switch strings.ToLower(v) { 733 case "--vscode": fvscode = true 734 case "--githubpages": fgithubpages = true 735 case "--sitemap": fsitemap = true 736 } 737 } 738 generateNewWebsite(args[0], args[1], fvscode, fgithubpages, fsitemap) 739 } 740 } 741 case "var": 742 if len(args) == 0 { 743 fmt.Println("var: filename expected") 744 } else { 745 s := "" 746 if vars, _, err := getVars(args[0], Vars{}); err != nil { 747 fmt.Println("var: " + err.Error()) 748 } else { 749 if len(args) > 1 { 750 for _, a := range args[1:] { 751 s = s + vars[a] + "\n" 752 } 753 } else { 754 for k, v := range vars { 755 s = s + k + ":" + v + "\n" 756 } 757 } 758 } 759 fmt.Println(strings.TrimSpace(s)) 760 } 761 default: 762 if s, err := run(globals(), cmd, args...); err != nil { 763 fmt.Println(err) 764 } else { 765 fmt.Println(s) 766 } 767 } 768 }