initial release
This is the initial release for a static content generator able to produce binaries that also contain and can deliver said content.
This commit is contained in:
commit
e0742b35d2
|
@ -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)
|
||||
}
|
|
@ -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))
|
||||
}
|
Loading…
Reference in New Issue