commit e0742b35d29cf8e94155518b2f85d647cc8f29f2 Author: Gibheer Date: Mon Sep 27 21:17:36 2021 +0200 initial release This is the initial release for a static content generator able to produce binaries that also contain and can deliver said content. diff --git a/catalog.go b/catalog.go new file mode 100644 index 0000000..876d1dd --- /dev/null +++ b/catalog.go @@ -0,0 +1,139 @@ +package internal + +import ( + "bytes" + "fmt" + "html/template" + "io/fs" + "net/http" + "time" + + "github.com/pelletier/go-toml" + "github.com/yuin/goldmark" +) + +const ( + tomlStart = "+++\n" + tomlEnd = "\n+++\n" +) + +type ( + CatalogOptions struct { + WithUnpublished bool // render unpublished documents? + DefaultTemplate string // the default template name + } + + Catalog struct { + Categories map[string]Category + Content map[string]*Content + } + + Category struct { + Content map[string]*Content + } + + Content struct { + // Metadata + Path string + Title string // the Title of this post + Author string // the list of authors that worked on the post + Published bool // Is the content published? + Time time.Time // the Time this post was published + Categories []string // The different Categories the content falls into + Template string // Template to use to render + Content template.HTML // the rendered Content + } + + Responses map[string]*Response + + Response struct { + Header http.Header + Modified time.Time + Content []byte + } +) + +func NewCatalog(files fs.FS, cfg CatalogOptions) (*Catalog, error) { + if cfg.DefaultTemplate == "" { + return nil, fmt.Errorf("no default template set") + } + cat := &Catalog{ + Content: map[string]*Content{}, + } + + if err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error { + if d.IsDir() { + return nil + } + raw, err := fs.ReadFile(files, path) + if err != nil { + return fmt.Errorf("could not read file '%s': %w", path, err) + } + cont := &Content{Path: path} + err = parseContent(raw, cont) + if err != nil { + return fmt.Errorf("file '%s' could not be parsed: %w", path, err) + } + if !cont.Published && !cfg.WithUnpublished { + return nil + } + if cont.Template == "" { + cont.Template = cfg.DefaultTemplate + } + cat.Content[cont.Path] = cont + return nil + }); err != nil { + return nil, fmt.Errorf("error occured when parsing content files: %w", err) + } + return cat, nil +} + +func (c *Catalog) Render(tmpl *template.Template) (Responses, error) { + pages := map[string]*Response{} + buf := &bytes.Buffer{} + for path, content := range c.Content { + buf.Reset() + err := tmpl.ExecuteTemplate(buf, content.Template, content) + if err != nil { + return nil, fmt.Errorf("rendering content '%s' failed: %w", path, err) + } + pages[path] = &Response{ + Header: map[string][]string{ + "Content-Type": {"text/html"}, + }, + Modified: content.Time, + Content: buf.Bytes(), + } + } + return (Responses)(pages), nil +} + +func parseContent(raw []byte, c *Content) error { + if !bytes.HasPrefix(raw, []byte(tomlStart)) { + return fmt.Errorf("no metadata header found") + } + end := bytes.Index(raw, []byte(tomlEnd)) + if end < 0 { + return fmt.Errorf("incomplete header") + } + header, err := toml.LoadBytes(raw[len(tomlStart) : end+1]) + if err != nil { + return fmt.Errorf("could not load header: %w", err) + } + if err := header.Unmarshal(c); err != nil { + return fmt.Errorf("could not parse header: %w", err) + } + raw = raw[end+len(tomlEnd):] + + md := goldmark.New() + var buf bytes.Buffer + if err := md.Convert(raw, &buf); err != nil { + return fmt.Errorf("could not render content: %w", err) + } + c.Content = template.HTML(buf.String()) + return nil +} + +func (c *Content) String() string { + return fmt.Sprintf("file '%s' - title '%s'", c.Path, c.Title) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..3fb38c1 --- /dev/null +++ b/main.go @@ -0,0 +1,96 @@ +package main + +import ( + "bytes" + "embed" + "flag" + "html/template" + "io/fs" + "log" + "net/http" + "os" + "path" + + "git.zero-knowledge.org/gibheer/zero-blog/internal" +) + +var ( + listen = flag.String("listen", "127.0.0.1:9292", "set the listen address for the web server") + withUnpublished = flag.Bool("with-unpublished", false, "set to true when unpublished content should also be rendered") + defaultTemplate = flag.String("default-template", "default.html", "the name of the default template to use for content pages") + prefix = flag.String("prefix", "/", "the prefix to use for all paths") + dumpFiles = flag.String("dump-files", "", "set an output directory to dump all files into") + //go:embed files templates + embeddedDir embed.FS +) + +func main() { + flag.Parse() + + // templates + tmplDir, err := fs.Sub(embeddedDir, "templates") + if err != nil { + log.Fatalf("could not access template directory: %s", err) + } + tmpls, err := template.ParseFS(tmplDir, "*.html") + if err != nil { + log.Fatalf("could not parse templates: %s", err) + } + // posts + contentDir, err := fs.Sub(embeddedDir, "files/content") + if err != nil { + log.Fatalf("could not access files dir: %s", err) + } + + catalog, err := internal.NewCatalog(contentDir, internal.CatalogOptions{ + WithUnpublished: *withUnpublished, + DefaultTemplate: *defaultTemplate, + }) + if err != nil { + log.Fatalf("could not parse catalog: %s", err) + } + + pages, err := catalog.Render(tmpls) + if err != nil { + log.Fatalf("rendering pages failed: %s", err) + } + catalog = nil + + // When in dump mode export everything and then end program. + if *dumpFiles != "" { + if err := os.MkdirAll(*dumpFiles, 0755); err != nil && !os.IsExist(err) { + log.Fatalf("could not create target directory: %s", err) + } + realPath := "" + for filePath, cont := range pages { + realPath = path.Join(*dumpFiles, *prefix, filePath) + if err := os.MkdirAll(path.Dir(realPath), 0755); err != nil && !os.IsExist(err) { + log.Fatalf("could not create parent directories for '%s': %s", realPath, err) + } + if err := os.WriteFile(realPath, cont.Content, 0644); err != nil { + log.Fatalf("could not write file '%s': %s", realPath, err) + } + } + return + } + + for path, cont := range pages { + log.Printf("adding '%s' to serve", *prefix+path) + handleCont := cont + http.DefaultServeMux.HandleFunc(*prefix+path, func(w http.ResponseWriter, r *http.Request) { + for key, header := range handleCont.Header { + w.Header().Add(key, header[0]) + } + http.ServeContent(w, r, "", handleCont.Modified, bytes.NewReader(handleCont.Content)) + return + }) + } + + // static files + static, err := fs.Sub(embeddedDir, "files/static") + if err != nil { + log.Fatalf("directory `files/static` could not be extracted: %s", err) + } + http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(static)))) + log.Fatalf("server stopped: %s", http.ListenAndServe(*listen, nil)) +}