weiro/providers/sitebuilder/builder.go

283 lines
6.1 KiB
Go

package sitebuilder
import (
"bytes"
"context"
"fmt"
"html/template"
"io"
"iter"
"os"
"path/filepath"
"strings"
"time"
"github.com/gopherlibs/feedhub/feedhub"
"golang.org/x/sync/errgroup"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/models/pubmodel"
"lmika.dev/lmika/weiro/providers/markdown"
)
type Builder struct {
site pubmodel.Site
mdRenderer *markdown.Renderer
opts Options
tmpls *template.Template
}
func New(site pubmodel.Site, opts Options) (*Builder, error) {
tmpls, err := template.New("").
Funcs(templateFns(site, opts)).
ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain)
if err != nil {
return nil, err
}
return &Builder{
site: site,
opts: opts,
tmpls: tmpls,
mdRenderer: markdown.NewRendererForSite(),
}, nil
}
func (b *Builder) BuildSite(outDir string) error {
buildCtx := buildContext{outDir: outDir}
if err := os.RemoveAll(outDir); err != nil {
return err
}
eg, ctx := errgroup.WithContext(context.Background())
eg.Go(func() error {
for mp := range b.site.PostIter(ctx) {
post, err := mp.Get()
if err != nil {
return err
}
if err := b.writePost(buildCtx, post); err != nil {
return err
}
}
return nil
})
eg.Go(func() error {
if err := b.renderPostList(buildCtx, b.site.PostIter(ctx)); err != nil {
return err
}
return nil
})
eg.Go(func() error {
if err := b.renderFeeds(buildCtx, b.site.PostIter(ctx)); err != nil {
return err
}
return nil
})
// Copy uploads
eg.Go(func() error {
if err := b.writeUploads(buildCtx, b.site.Uploads); err != nil {
return err
}
return nil
})
if err := eg.Wait(); err != nil {
return err
}
return nil
}
func (b *Builder) renderPostList(ctx buildContext, postIter iter.Seq[models.Maybe[*models.Post]]) error {
// TODO: paging
postCopy := make([]*models.Post, 0)
for mp := range postIter {
post, err := mp.Get()
if err != nil {
return err
}
postCopy = append(postCopy, post)
}
pl := postListData{
commonData: commonData{Site: b.site},
}
for _, post := range postCopy {
rp, err := b.renderPost(post)
if err != nil {
return err
}
pl.Posts = append(pl.Posts, rp)
}
return b.createAtPath(ctx, "", func(f io.Writer) error {
return b.renderTemplate(f, tmplNamePostList, pl)
})
}
func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[*models.Post]]) error {
now := time.Now()
feed := &feedhub.Feed{
Title: b.site.Title,
Link: &feedhub.Link{Href: b.site.BaseURL},
Description: b.site.Tagline,
// TO FIX: Author
Created: now,
}
items := 0
for mp := range postIter {
post, err := mp.Get()
if err != nil {
return fmt.Errorf("failed to get post: %w", err)
}
renderedPost, err := b.renderPost(post)
if err != nil {
return err
}
feed.Items = append(feed.Items, &feedhub.Item{
Id: filepath.Join(b.site.BaseURL, post.GUID),
Title: post.Title,
Link: &feedhub.Link{Href: renderedPost.PostURL},
Content: string(renderedPost.HTML),
// TO FIX: Created should be first published
Created: post.PublishedAt,
Updated: post.UpdatedAt,
})
items++
if items >= b.opts.FeedItems {
break
}
}
if err := b.createAtPath(ctx, "/feed.xml", func(f io.Writer) error {
rss, err := feed.ToRss()
if err != nil {
return fmt.Errorf("failed to convert feed to RSS: %w", err)
}
_, err = io.WriteString(f, rss)
return err
}); err != nil {
return err
}
if err := b.createAtPath(ctx, "/feed.json", func(f io.Writer) error {
rss, err := feed.ToJSON()
if err != nil {
return fmt.Errorf("failed to convert feed to JSON feed: %w", err)
}
_, err = io.WriteString(f, rss)
return err
}); err != nil {
return err
}
return nil
}
func (b *Builder) renderPost(post *models.Post) (postSingleData, error) {
postPath := post.Slug
if b.opts.BasePosts != "" {
postPath = filepath.Join(b.opts.BasePosts, strings.TrimPrefix(postPath, "/"))
}
var md bytes.Buffer
if err := b.mdRenderer.RenderTo(context.Background(), &md, post.Body); err != nil {
return postSingleData{}, fmt.Errorf("failed to write post %s: %w", post.Slug, err)
}
postURL := strings.TrimSuffix(b.site.BaseURL, "/") + "/" + strings.TrimPrefix(postPath, "/")
return postSingleData{
commonData: commonData{Site: b.site},
Path: postPath,
Post: post,
PostURL: postURL,
HTML: template.HTML(md.String()),
}, nil
}
func (b *Builder) writePost(ctx buildContext, post *models.Post) error {
rp, err := b.renderPost(post)
if err != nil {
return err
}
return b.createAtPath(ctx, rp.Path, func(f io.Writer) error {
return b.renderTemplate(f, tmplNamePostSingle, rp)
})
}
func (b *Builder) createAtPath(ctx buildContext, path string, fn func(f io.Writer) error) error {
outFile := filepath.Join(ctx.outDir, strings.TrimPrefix(path, "/"))
if filepath.Ext(outFile) == "" {
outFile = filepath.Join(outFile, "index.html")
}
// Render it within the template
if err := os.MkdirAll(filepath.Dir(outFile), 0755); err != nil {
return err
}
f, err := os.Create(outFile)
if err != nil {
return err
}
defer f.Close()
return fn(f)
}
func (b *Builder) renderTemplate(w io.Writer, name string, data interface{}) error {
var buf bytes.Buffer
if err := b.tmpls.ExecuteTemplate(&buf, name, data); err != nil {
return err
}
return b.tmpls.ExecuteTemplate(w, tmplNameLayoutMain, layoutData{
commonData: commonData{Site: b.site},
Body: template.HTML(buf.String()),
})
}
func (b *Builder) writeUploads(ctx buildContext, uploads []models.Upload) error {
for _, u := range uploads {
fullPath := filepath.Join(ctx.outDir, "uploads", u.Slug)
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
return err
}
if err := func() error {
r, err := b.site.OpenUpload(u)
if err != nil {
return err
}
defer r.Close()
w, err := os.Create(fullPath)
if err != nil {
return err
}
defer w.Close()
if _, err := io.Copy(w, r); err != nil {
return err
}
return nil
}(); err != nil {
return err
}
}
return nil
}
type buildContext struct {
outDir string
}