You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
218 lines
5.4 KiB
218 lines
5.4 KiB
1 year ago
|
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
|
||
|
}
|