package main import ( "log" "fmt" "bytes" "strings" "io/fs" "io" "path/filepath" "os" "flag" "time" "text/template" "git.learnjsthehardway.com/learn-code-the-hard-way/ssgod/config" "github.com/yuin/goldmark" "github.com/fsnotify/fsnotify" ) var DEFAULT_CONFIG = ` views = "pages" layout = "pages/layouts/main.html" target = "public" watch_delay = "500ms" ` func Fail(err error, format string, v ...any) error { err_format := fmt.Sprintf("ERROR: %v; %s", err, format) log.Printf(err_format, v...) return err } func RenderTemplate(out io.Writer, embed string, variables any) (error) { layout_path := config.Settings.Layout layout_main, err := os.ReadFile(layout_path) if err != nil { return Fail(err, "can't read your layout file: %s", layout_path) } tmpl := template.New(layout_path) callbacks := template.FuncMap{ "embed": func() string { return embed }, } tmpl.Funcs(callbacks) tmpl, err = tmpl.Parse(string(layout_main)) if err != nil { return Fail(err, "can't parse %s", layout_path) } err = tmpl.Execute(out, variables) return err } func RenderMarkdown(path string, target_path string, page_id string) error { log.Printf("MARKDOWN: %s -> %s", path, target_path) out, err := os.OpenFile(target_path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) defer out.Close() if err != nil { return Fail(err, "writing file %s", target_path) } input_data, err := os.ReadFile(path) var md_out bytes.Buffer err = goldmark.Convert(input_data, &md_out) if err != nil { return Fail(err, "failed converting markdown %s", path) } err = RenderTemplate(out, md_out.String(), map[string]string{"PageId": page_id}) if err != nil { return Fail(err, "failed to render template %s->%s", path, target_path) } return err; } func RenderHTML(source_path string, target_path string, page_id string) error { log.Printf("RENDER: %s -> %s", source_path, target_path) out, err := os.OpenFile(target_path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) defer out.Close() html_content, err := os.ReadFile(source_path) if err != nil { return Fail(err, "cannot open html input %s", source_path) } err = RenderTemplate(out, string(html_content), map[string]string{"PageId": page_id}) if err != nil { return Fail(err, "writing file %s", target_path) } return err; } func MkdirPath(target_path string) error { target_dir := filepath.Dir(target_path) _, err := os.Stat(target_dir) if os.IsNotExist(err) { log.Println("MAKING: ", target_dir) err = os.MkdirAll(target_dir, 0750) if err != nil { return Fail(err, "making path to %s", target_dir); } } return nil; } func SplitPathExt(path string) (string, string, bool) { split_path := strings.Split(path, string(os.PathSeparator))[1:] source_name := strings.Join(split_path, "/") // Render wants / even on windows ext := filepath.Ext(source_name) source_name, found := strings.CutSuffix(source_name, ext) return source_name, ext, found } func RePrefixPath(path string, new_prefix string) string { split_path := strings.Split(path, string(os.PathSeparator))[1:] prefixed_path := append([]string{new_prefix}, split_path...) return filepath.Join(prefixed_path...) } func ProcessDirEntry(path string, d fs.DirEntry, err error) error { settings := config.Settings if !d.IsDir() { if err != nil { return Fail(err, "path: %s", path); } source_name, ext, found := SplitPathExt(path) if found && path != settings.Layout { target_path := RePrefixPath(path, settings.Target) err = MkdirPath(target_path) if err != nil { return Fail(err, "making target path: %s", target_path) } // generate a data-testid for all pages based on template name page_id := strings.ReplaceAll(source_name, "/", "-") + "-page" if ext == ".html" { err = RenderHTML(path, target_path, page_id) if err != nil { return Fail(err, "failed to render %s", path) } } else if ext == ".md" { // need to strip the .md and replace with .html html_name, _ := strings.CutSuffix(target_path, ext) html_name = fmt.Sprintf("%s.html", html_name) RenderMarkdown(path, html_name, page_id) if err != nil { return Fail(err, "failed to render markdown %s", path) } } } } return nil } func RenderPages() { err := filepath.WalkDir(config.Settings.Views, func (path string, d fs.DirEntry, err error) error { return ProcessDirEntry(path, d, err) }) if err != nil { log.Fatalf("can't walk content") } } func WatchMatches(name string) bool { return filepath.Ext(name) == ".html" || filepath.Ext(name) == ".md" } func WatchDir() { watcher, err := fsnotify.NewWatcher() if err != nil { log.Fatal(err) } defer watcher.Close() wait_time, err := time.ParseDuration(config.Settings.WatchDelay) if err != nil { log.Fatal(err) } doit := time.NewTimer(wait_time) doit.Stop() go func() { for { select { case event, ok := <-watcher.Events: if !ok { return } log.Println("event: ", event) if WatchMatches(event.Name) { log.Println("modified file: ", event.Name) doit.Reset(wait_time) } case <-doit.C: RenderPages() case err, ok := <-watcher.Errors: if !ok { return } log.Println("error: ", err) } } }() err = filepath.WalkDir(config.Settings.Views, func (path string, d fs.DirEntry, err error) error { if d.IsDir() { log.Println("WATCHING: ", path) err = watcher.Add(path) if err != nil { return Fail(err, "failed to watch %s", path) } } return nil }) if err != nil { log.Fatalf("can't walk content") } <-make(chan struct{}) } func InitConfig(config_file string) { _, err := os.Stat(config_file) if os.IsNotExist(err) { out, err := os.OpenFile(config_file, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { log.Fatalf("error opening %s", config_file) } defer out.Close() out.WriteString(DEFAULT_CONFIG) } else { log.Fatalf("there's already a %s file here", config_file); } } func main() { var config_file string flag.StringVar(&config_file, "config", "ssgod.toml", ".toml config file to use") flag.Parse() command := flag.Arg(0) switch command { case "watch": config.Load(config_file) WatchDir() case "init": InitConfig(config_file) default: config.Load(config_file) RenderPages(); } }