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