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 }