package main import ( "bytes" "flag" "fmt" "html/template" "io" "io/fs" "log" "net/http" "os" "path" "path/filepath" "time" "github.com/russross/blackfriday/v2" ) var ( contentDir = flag.String("content-dir", "content", "path to the content directory") staticDir = flag.String("static-dir", "static", "path to the static files") templateDir = flag.String("template-dir", "templates", "path to the template directory") outputDir = flag.String("output-dir", "", "path to output all files from the render process") listen = flag.String("listen", "", "When provided with a listen port, start serving the content") ) type ( Metadata struct { URLPath string FilePath string Template string Title string Date time.Time Author string Draft bool } ) func main() { flag.Parse() var err error tmplDirFS := os.DirFS(*templateDir) templates := template.New("") templates = templates.Funcs(template.FuncMap( map[string]interface{}{ "formatTime": func(t time.Time) string { return t.Format("2006-01-02") }, }, )) templates, err = templates.ParseFS(tmplDirFS, "*") if err != nil { log.Fatalf("could not parse template files: %s", err) } content := []Metadata{} if err := filepath.Walk(*contentDir, func(path string, info fs.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } m, err := MetadataFromFile(*contentDir, path) if err != nil { return fmt.Errorf("could not parse metadata from '%s': %w", path, err) } content = append(content, m) return nil }); err != nil { log.Fatalf("could not read content: %s", err) } if *outputDir != "" { for _, metadata := range content { p := *outputDir + metadata.URLPath if p[len(p)-1] == '/' { p = path.Join(p, "index.html") } // create directory if _, err := os.Stat(path.Dir(p)); os.IsNotExist(err) { if err := os.MkdirAll(path.Dir(p), 0755); err != nil { log.Fatalf("could not create directory '%s': %s", path.Dir(p), err) } } f, err := os.Create(p) if err != nil { log.Fatalf("could not create new file '%s': %s", p, err) } defer f.Close() if err := metadata.Render(f, templates); err != nil { log.Fatalf("could not render '%s': %s", metadata.FilePath, err) } f.Close() } } if *listen != "" { http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(*staticDir)))) for _, metadata := range content { func(m Metadata) { http.HandleFunc(m.URLPath, func(w http.ResponseWriter, r *http.Request) { log.Printf("%s -> %s", r.URL, m.URLPath) w.Header()["Content-Type"] = []string{"text/html"} if err := m.Render(w, templates); err != nil { log.Printf("could not render '%s': %s", m.FilePath, err) } }) }(metadata) } log.Fatalf("stopped listening: %s", http.ListenAndServe(*listen, nil)) } if *outputDir == "" && *listen == "" { log.Printf("neither output-dir nor listen are requested - doing nothing") } } var ( metadataStart = []byte("+++\n") metadataEnd = []byte("\n+++\n") headerTitle = "title" headerDate = "date" headerAuthor = "author" headerURLPath = "url" headerDraft = "draft" headerTemplate = "template" ) // ContentFromFile reads the header of the file to create the metadata. // // basePath is stripped from the path when generating the default URL path. func MetadataFromFile(basePath string, path string) (Metadata, error) { m := Metadata{ FilePath: path, URLPath: path[len(basePath):], Template: "content.html", } raw, err := os.ReadFile(m.FilePath) if err != nil { return m, err } if !bytes.HasPrefix(raw, metadataStart) { return m, fmt.Errorf("missing metadata header, must start with +++") } last := bytes.Index(raw, metadataEnd) if last == -1 { return m, fmt.Errorf("missing metadata header, must end with +++ on a single line") } rawHeader := raw[len(metadataStart):last] lineNum := 0 for _, headerLine := range bytes.Split(rawHeader, []byte("\n")) { if len(headerLine) == 0 { continue } line := bytes.SplitN(headerLine, []byte("="), 2) if len(line) != 2 { return m, fmt.Errorf("line %d: format must be 'key = value'", lineNum) } key := string(bytes.Trim(line[0], " ")) val := string(bytes.Trim(line[1], ` "'`)) switch string(key) { case headerTitle: m.Title = val case headerAuthor: m.Author = val case headerDraft: if headerDraft == "true" { m.Draft = true } case headerTemplate: m.Template = val case headerDate: m.Date, err = time.Parse(time.RFC3339, val) if err != nil { log.Printf("line %d: date must match RFC3339 format", lineNum) } case headerURLPath: m.URLPath = val default: log.Printf("line %d: unknown header %s found in %s", lineNum, key, path) } lineNum += 1 } return m, nil } func (m Metadata) Content() template.HTML { result := "" raw, err := os.ReadFile(m.FilePath) if err != nil { log.Printf("error reading file: %w", err) return template.HTML("") } end := bytes.Index(raw, metadataEnd) if end == -1 { log.Printf("could not find metadata end") return template.HTML("") } result = string(blackfriday.Run(raw[end+len(metadataEnd):])) return template.HTML(result) } func (m Metadata) Render(w io.Writer, tmpl *template.Template) error { if err := tmpl.ExecuteTemplate(w, m.Template, m); err != nil { return fmt.Errorf("could not render content path '%s': %w", m.FilePath, err) } return nil }