git.mills.io/prologic/zs@v0.0.0-20230312034417-0f4623afae54/main.go (about)

     1  package main
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"io"
     7  	"io/ioutil"
     8  	"log"
     9  	"os"
    10  	"os/exec"
    11  	"path/filepath"
    12  	"strings"
    13  	"text/template"
    14  	"time"
    15  
    16  	"github.com/russross/blackfriday/v2"
    17  	"gopkg.in/yaml.v2"
    18  )
    19  
    20  const (
    21  	ZSDIR  = ".zs"
    22  	PUBDIR = ".pub"
    23  )
    24  
    25  type Vars map[string]string
    26  
    27  // renameExt renames extension (if any) from oldext to newext
    28  // If oldext is an empty string - extension is extracted automatically.
    29  // If path has no extension - new extension is appended
    30  func renameExt(path, oldext, newext string) string {
    31  	if oldext == "" {
    32  		oldext = filepath.Ext(path)
    33  	}
    34  	if oldext == "" || strings.HasSuffix(path, oldext) {
    35  		return strings.TrimSuffix(path, oldext) + newext
    36  	}
    37  	return path
    38  }
    39  
    40  // globals returns list of global OS environment variables that start
    41  // with ZS_ prefix as Vars, so the values can be used inside templates
    42  func globals() Vars {
    43  	vars := Vars{}
    44  	for _, e := range os.Environ() {
    45  		pair := strings.Split(e, "=")
    46  		if strings.HasPrefix(pair[0], "ZS_") {
    47  			vars[strings.ToLower(pair[0][3:])] = pair[1]
    48  		}
    49  	}
    50  	return vars
    51  }
    52  
    53  // run executes a command or a script. Vars define the command environment,
    54  // each zs var is converted into OS environemnt variable with ZS_ prefix
    55  // prepended.  Additional variable $ZS contains path to the zs binary. Command
    56  // stderr is printed to zs stderr, command output is returned as a string.
    57  func run(vars Vars, cmd string, args ...string) (string, error) {
    58  	// First check if partial exists (.html)
    59  	if b, err := ioutil.ReadFile(filepath.Join(ZSDIR, cmd+".html")); err == nil {
    60  		return string(b), nil
    61  	}
    62  
    63  	var errbuf, outbuf bytes.Buffer
    64  	c := exec.Command(cmd, args...)
    65  	env := []string{"ZS=" + os.Args[0], "ZS_OUTDIR=" + PUBDIR}
    66  	env = append(env, os.Environ()...)
    67  	for k, v := range vars {
    68  		env = append(env, "ZS_"+strings.ToUpper(k)+"="+v)
    69  	}
    70  	c.Env = env
    71  	c.Stdout = &outbuf
    72  	c.Stderr = &errbuf
    73  
    74  	err := c.Run()
    75  
    76  	if errbuf.Len() > 0 {
    77  		log.Println("ERROR:", errbuf.String())
    78  	}
    79  	if err != nil {
    80  		return "", err
    81  	}
    82  	return string(outbuf.Bytes()), nil
    83  }
    84  
    85  // getVars returns list of variables defined in a text file and actual file
    86  // content following the variables declaration. Header is separated from
    87  // content by an empty line. Header can be either YAML or JSON.
    88  // If no empty newline is found - file is treated as content-only.
    89  func getVars(path string, globals Vars) (Vars, string, error) {
    90  	b, err := ioutil.ReadFile(path)
    91  	if err != nil {
    92  		return nil, "", err
    93  	}
    94  	s := string(b)
    95  
    96  	// Pick some default values for content-dependent variables
    97  	v := Vars{}
    98  	title := strings.Replace(strings.Replace(path, "_", " ", -1), "-", " ", -1)
    99  	v["title"] = strings.ToTitle(title)
   100  	v["description"] = ""
   101  	v["file"] = path
   102  	v["url"] = path[:len(path)-len(filepath.Ext(path))] + ".html"
   103  	v["output"] = filepath.Join(PUBDIR, v["url"])
   104  
   105  	// Override default values with globals
   106  	for name, value := range globals {
   107  		v[name] = value
   108  	}
   109  
   110  	// Add layout if none is specified
   111  	if _, ok := v["layout"]; !ok {
   112  		v["layout"] = "layout.html"
   113  	}
   114  
   115  	delim := "\n---\n"
   116  	if sep := strings.Index(s, delim); sep == -1 {
   117  		return v, s, nil
   118  	} else {
   119  		header := s[:sep]
   120  		body := s[sep+len(delim):]
   121  
   122  		vars := Vars{}
   123  		if err := yaml.Unmarshal([]byte(header), &vars); err != nil {
   124  			fmt.Println("ERROR: failed to parse header", err)
   125  			return nil, "", err
   126  		} else {
   127  			// Override default values + globals with the ones defines in the file
   128  			for key, value := range vars {
   129  				v[key] = value
   130  			}
   131  		}
   132  		if strings.HasPrefix(v["url"], "./") {
   133  			v["url"] = v["url"][2:]
   134  		}
   135  		return v, body, nil
   136  	}
   137  }
   138  
   139  // Render expanding zs plugins and variables
   140  func render(s string, vars Vars) (string, error) {
   141  	delim_open := "{{"
   142  	delim_close := "}}"
   143  
   144  	out := &bytes.Buffer{}
   145  	for {
   146  		if from := strings.Index(s, delim_open); from == -1 {
   147  			out.WriteString(s)
   148  			return out.String(), nil
   149  		} else {
   150  			if to := strings.Index(s, delim_close); to == -1 {
   151  				return "", fmt.Errorf("Close delim not found")
   152  			} else {
   153  				out.WriteString(s[:from])
   154  				cmd := s[from+len(delim_open) : to]
   155  				s = s[to+len(delim_close):]
   156  				m := strings.Fields(cmd)
   157  				if len(m) == 1 {
   158  					if v, ok := vars[m[0]]; ok {
   159  						out.WriteString(v)
   160  						continue
   161  					}
   162  				}
   163  				if res, err := run(vars, m[0], m[1:]...); err == nil {
   164  					out.WriteString(res)
   165  				} else {
   166  					fmt.Println(err)
   167  				}
   168  			}
   169  		}
   170  	}
   171  
   172  }
   173  
   174  // Renders markdown with the given layout into html expanding all the macros
   175  func buildMarkdown(path string, w io.Writer, vars Vars) error {
   176  	v, body, err := getVars(path, vars)
   177  	if err != nil {
   178  		return err
   179  	}
   180  	content, err := render(body, v)
   181  	if err != nil {
   182  		return err
   183  	}
   184  	v["content"] = string(blackfriday.Run(
   185  		[]byte(content),
   186  		blackfriday.WithExtensions(blackfriday.CommonExtensions|blackfriday.AutoHeadingIDs),
   187  	))
   188  	if w == nil {
   189  		out, err := os.Create(filepath.Join(PUBDIR, renameExt(path, "", ".html")))
   190  		if err != nil {
   191  			return err
   192  		}
   193  		defer out.Close()
   194  		w = out
   195  	}
   196  	return buildHTML(filepath.Join(ZSDIR, v["layout"]), w, v)
   197  }
   198  
   199  // Renders text file expanding all variable macros inside it
   200  func buildHTML(path string, w io.Writer, vars Vars) error {
   201  	v, body, err := getVars(path, vars)
   202  	if err != nil {
   203  		return err
   204  	}
   205  	if body, err = render(body, v); err != nil {
   206  		return err
   207  	}
   208  	tmpl, err := template.New("").Delims("<%", "%>").Parse(body)
   209  	if err != nil {
   210  		return err
   211  	}
   212  	if w == nil {
   213  		f, err := os.Create(filepath.Join(PUBDIR, path))
   214  		if err != nil {
   215  			return err
   216  		}
   217  		defer f.Close()
   218  		w = f
   219  	}
   220  	return tmpl.Execute(w, vars)
   221  }
   222  
   223  // Copies file as is from path to writer
   224  func buildRaw(path string, w io.Writer) error {
   225  	in, err := os.Open(path)
   226  	if err != nil {
   227  		return err
   228  	}
   229  	defer in.Close()
   230  	if w == nil {
   231  		if out, err := os.Create(filepath.Join(PUBDIR, path)); err != nil {
   232  			return err
   233  		} else {
   234  			defer out.Close()
   235  			w = out
   236  		}
   237  	}
   238  	_, err = io.Copy(w, in)
   239  	return err
   240  }
   241  
   242  func build(path string, w io.Writer, vars Vars) error {
   243  	ext := filepath.Ext(path)
   244  	if ext == ".md" || ext == ".mkd" {
   245  		return buildMarkdown(path, w, vars)
   246  	} else if ext == ".html" || ext == ".xml" {
   247  		return buildHTML(path, w, vars)
   248  	} else {
   249  		return buildRaw(path, w)
   250  	}
   251  }
   252  
   253  func buildAll(watch bool) {
   254  	lastModified := time.Unix(0, 0)
   255  	modified := false
   256  
   257  	vars := globals()
   258  	for {
   259  		os.Mkdir(PUBDIR, 0755)
   260  		filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
   261  			// ignore hidden files and directories
   262  			if filepath.Base(path)[0] == '.' || strings.HasPrefix(path, ".") {
   263  				return nil
   264  			}
   265  			// inform user about fs walk errors, but continue iteration
   266  			if err != nil {
   267  				fmt.Println("error:", err)
   268  				return nil
   269  			}
   270  
   271  			if info.IsDir() {
   272  				os.Mkdir(filepath.Join(PUBDIR, path), 0755)
   273  				return nil
   274  			} else if info.ModTime().After(lastModified) {
   275  				if !modified {
   276  					// First file in this build cycle is about to be modified
   277  					run(vars, "prehook")
   278  					modified = true
   279  				}
   280  				log.Println("build:", path)
   281  				return build(path, nil, vars)
   282  			}
   283  			return nil
   284  		})
   285  		if modified {
   286  			// At least one file in this build cycle has been modified
   287  			run(vars, "posthook")
   288  			modified = false
   289  		}
   290  		if !watch {
   291  			break
   292  		}
   293  		lastModified = time.Now()
   294  		time.Sleep(1 * time.Second)
   295  	}
   296  }
   297  
   298  func init() {
   299  	// prepend .zs to $PATH, so plugins will be found before OS commands
   300  	p := os.Getenv("PATH")
   301  	w, _ := os.Getwd()
   302  	p = filepath.Join(w, ZSDIR) + ":" + p
   303  	os.Setenv("PATH", p)
   304  }
   305  
   306  func main() {
   307  	if len(os.Args) == 1 {
   308  		fmt.Println(os.Args[0], "<command> [args]")
   309  		return
   310  	}
   311  	cmd := os.Args[1]
   312  	args := os.Args[2:]
   313  	switch cmd {
   314  	case "build":
   315  		if len(args) == 0 {
   316  			buildAll(false)
   317  		} else if len(args) == 1 {
   318  			if err := build(args[0], os.Stdout, globals()); err != nil {
   319  				fmt.Println("ERROR: " + err.Error())
   320  			}
   321  		} else {
   322  			fmt.Println("ERROR: too many arguments")
   323  		}
   324  	case "watch":
   325  		buildAll(true)
   326  	case "var":
   327  		if len(args) == 0 {
   328  			fmt.Println("var: filename expected")
   329  		} else {
   330  			s := ""
   331  			if vars, _, err := getVars(args[0], Vars{}); err != nil {
   332  				fmt.Println("var: " + err.Error())
   333  			} else {
   334  				if len(args) > 1 {
   335  					for _, a := range args[1:] {
   336  						s = s + vars[a] + "\n"
   337  					}
   338  				} else {
   339  					for k, v := range vars {
   340  						s = s + k + ":" + v + "\n"
   341  					}
   342  				}
   343  			}
   344  			fmt.Println(strings.TrimSpace(s))
   345  		}
   346  	default:
   347  		if s, err := run(globals(), cmd, args...); err != nil {
   348  			fmt.Println(err)
   349  		} else {
   350  			fmt.Println(s)
   351  		}
   352  	}
   353  }