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 }