package main 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) }