package main import ( "bytes" _ "embed" "flag" "fmt" "github.com/fsnotify/fsnotify" "github.com/yuin/goldmark" "io" "io/fs" "lcthw.dev/go/ssgod/config" "log" "os" "path/filepath" "strings" "text/template" "time" ) //go:embed example/.ssgod.json var DEFAULT_CONFIG string type PageMetaData struct { pages []string } func (meta *PageMetaData) AddPage(path string) { path = RePrefixPath(path, "/") path = filepath.ToSlash(path) meta.pages = append(meta.pages, path) } func Fatal(err error, format string, v ...any) { err_format := fmt.Sprintf("ERROR: %v; %s", err, format) panic(fmt.Sprintf(err_format, v...)) } func Fail(err error, format string, v ...any) error { err_format := fmt.Sprintf("ERROR: %v; %s", err, format) panic(fmt.Sprintf(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 { tmpl, err = tmpl.Parse(embed) if err != nil { Fatal(err, "error in your template") } out := bytes.NewBuffer(make([]byte, 0, 100)) tmpl.Execute(out, variables) return out.String() }, } 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 RenderMetaFiles(meta *PageMetaData) { fmt.Println("PAGES: ", meta.pages) for _, path := range config.Settings.MetaFiles { fmt.Println(">>> META >>>>", path) ext := filepath.Ext(path) target_path := RePrefixPath(path, config.Settings.Target) if ext == ".md" { RenderMarkdown(path, target_path, map[string]any{"Pages": meta.pages}) } else if ext == ".html" { RenderHTML(path, target_path, map[string]any{"Pages": meta.pages}) } } } func RenderMarkdown(path string, target_path string, vars map[string]any) error { log.Printf("MARKDOWN: %s -> %s", path, target_path) out, err := os.Create(target_path) 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(), vars) 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, vars map[string]any) error { log.Printf("RENDER: %s -> %s", source_path, target_path) out, err := os.Create(target_path) defer out.Close() content, err := os.ReadFile(source_path) if err != nil { return Fail(err, "cannot open input %s", source_path) } err = RenderTemplate(out, string(content), vars) 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 UnfuckedPathSplit(path string) []string { path = filepath.ToSlash(path) // WARN: have to use strings.Split because fsnotify uses /, even on windows return strings.Split(path, "/")[1:] } func SplitPathExt(path string) (string, string, bool) { split_path := UnfuckedPathSplit(path) 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 := UnfuckedPathSplit(path) prefixed_path := append([]string{new_prefix}, split_path...) res := filepath.Join(prefixed_path...) return res } func SamePath(a string, b string) bool { return filepath.ToSlash(a) == filepath.ToSlash(b) } func ProcessDirEntry(path string, d fs.DirEntry, meta *PageMetaData) error { settings := config.Settings var err error = nil if !d.IsDir() && !SamePath(path, config.Settings.Layout) { 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, map[string]any{"PageId": page_id}) if err != nil { return Fail(err, "failed to render %s", path) } meta.AddPage(target_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, map[string]any{"PageId": page_id}) if err != nil { return Fail(err, "failed to render markdown %s", path) } meta.AddPage(html_name) } } } return nil } func SyncStaticDir() { target := config.Settings.Target sync_dir := config.Settings.SyncDir if sync_dir == "" { return } log.Printf("removing target directory: %s", target) err := os.RemoveAll(target) if err != nil { Fatal(err, "can't remove target directory: %s", target) } err = os.MkdirAll(target, 0750) if err != nil { Fatal(err, "can't recreate target directory: %s", target) } source := os.DirFS(sync_dir) log.Printf("SYNC %s -> %s", sync_dir, target) err = os.CopyFS(target, source) if err != nil { Fatal(err, "can't sync %s to target directory: %s", sync_dir, target) } } func RenderPages() { meta := new(PageMetaData) err := filepath.WalkDir(config.Settings.Views, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } err = ProcessDirEntry(path, d, meta) if err != nil { return Fail(err, "failed to process %s", path) } return err }) if err != nil { Fatal(err, "can't walk content") } RenderMetaFiles(meta) } func WatchMatches(name string) bool { is_static := strings.Index(name, config.Settings.SyncDir) == 0 return is_static || filepath.Ext(name) == ".html" || filepath.Ext(name) == ".md" } func AddWatchDir(watcher *fsnotify.Watcher, name string) error { return filepath.WalkDir(name, func(path string, d fs.DirEntry, err error) error { if err != nil { log.Printf("WATCH ERROR! walking=%s path=%s: err=%v", name, path, err) return err } if d.IsDir() { log.Println("WATCHING: ", path) err = watcher.Add(path) if err != nil { log.Printf("failed to watch %s", path) return err } } return nil }) } func WatchDir() { watcher, err := fsnotify.NewWatcher() if err != nil { Fatal(err, "can't create new watcher") } defer watcher.Close() wait_time, err := time.ParseDuration(config.Settings.WatchDelay) if err != nil { Fatal(err, "can't parse watch_delay setting: %s", config.Settings.WatchDelay) } doit := time.NewTimer(wait_time) doit.Stop() go func() { for { select { case event, ok := <-watcher.Events: if !ok { return } if event.Has(fsnotify.Create) { log.Println("---> CREATE IS:", event.Name) AddWatchDir(watcher, event.Name) } else { log.Println("event: ", event) } if WatchMatches(event.Name) { log.Println("modified file: ", event.Name) doit.Reset(wait_time) } case <-doit.C: SyncStaticDir() RenderPages() case err, ok := <-watcher.Errors: if !ok { return } log.Println("error: ", err) } } }() err = AddWatchDir(watcher, config.Settings.Views) if err != nil { Fatal(err, "failed to watch %s", config.Settings.Views) } err = AddWatchDir(watcher, filepath.Dir(config.Settings.Layout)) if err != nil { Fatal(err, "failed to watch %s", filepath.Dir(config.Settings.Layout)) } if config.Settings.SyncDir != "" { err = AddWatchDir(watcher, config.Settings.SyncDir) if err != nil { Fatal(err, "failed to watch %s", config.Settings.SyncDir) } } <-make(chan struct{}) } func InitConfig(config_file string) { _, err := os.Stat(config_file) if os.IsNotExist(err) { out, err := os.Create(config_file) if err != nil { Fatal(err, "error opening %s", config_file) } defer out.Close() out.WriteString(DEFAULT_CONFIG) fmt.Println("new config written to:", config_file) } else { Fatal(err, "there's already a %s file here", config_file) } } func main() { var config_file string flag.StringVar(&config_file, "config", ".ssgod.json", ".json config file to use") flag.Parse() command := flag.Arg(0) switch command { case "watch": config.Load(config_file) SyncStaticDir() RenderPages() WatchDir() case "init": InitConfig(config_file) default: config.Load(config_file) SyncStaticDir() RenderPages() } }