package sitebuilder import ( "bytes" "context" "fmt" "html/template" "io" "iter" "os" "path/filepath" "strings" "time" "github.com/PuerkitoBio/goquery" "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 postMDProcessors []postMDProcessor } func New(site pubmodel.Site, opts Options) (*Builder, error) { tmpls, err := template.New(""). Funcs(templateFns(site, opts)). ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain, tmplNameCategoryList, tmplNameCategorySingle) if err != nil { return nil, err } return &Builder{ site: site, opts: opts, tmpls: tmpls, mdRenderer: markdown.NewRendererForSite(), postMDProcessors: []postMDProcessor{ uploadAbsoluteURL, }, }, 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 } rp, err := b.renderPostWithCategories(ctx, post) if err != nil { return err } if err := b.createAtPath(buildCtx, rp.Path, func(f io.Writer) error { return b.renderTemplate(f, tmplNamePostSingle, rp) }); err != nil { return err } } return nil }) eg.Go(func() error { return b.renderPostListWithCategories(buildCtx, ctx) }) eg.Go(func() error { if err := b.renderFeeds(buildCtx, b.site.PostIter(ctx), feedOptions{ targetNamePrefix: "/feed", titlePrefix: "", }); err != nil { return err } if err := b.renderFeeds(buildCtx, b.site.PostIter(ctx), feedOptions{ targetNamePrefix: "/feeds/microblog-crosspost", titlePrefix: "Devlog: ", }); err != nil { return err } return nil }) // Category pages eg.Go(func() error { if err := b.renderCategoryList(buildCtx); err != nil { return err } return b.renderCategoryPages(buildCtx, ctx) }) // Copy uploads eg.Go(func() error { return b.writeUploads(buildCtx, b.site.Uploads) }) return eg.Wait() } func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Context) error { var posts []postSingleData for mp := range b.site.PostIter(ctx) { post, err := mp.Get() if err != nil { return err } rp, err := b.renderPostWithCategories(ctx, post) if err != nil { return err } posts = append(posts, rp) } pl := postListData{ commonData: commonData{Site: b.site}, Posts: posts, } return b.createAtPath(bctx, "", func(f io.Writer) error { return b.renderTemplate(f, tmplNamePostList, pl) }) } func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[*models.Post]], opts feedOptions) 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 } var catName string if b.site.CategoriesOfPost != nil { cats, err := b.site.CategoriesOfPost(context.Background(), post.ID) if err == nil && len(cats) > 0 { names := make([]string, len(cats)) for i, c := range cats { names[i] = c.Name } catName = strings.Join(names, ", ") } } postTitle := post.Title if postTitle != "" { postTitle = opts.titlePrefix + postTitle } feed.Items = append(feed.Items, &feedhub.Item{ Id: filepath.Join(b.site.BaseURL, post.GUID), Title: postTitle, Link: &feedhub.Link{Href: renderedPost.PostURL}, Content: string(renderedPost.HTML), // TO FIX: Why the heck does this only include the first category? Category: catName, // 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, opts.targetNamePrefix+".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, opts.targetNamePrefix+".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) } if len(b.postMDProcessors) > 0 { dom, err := goquery.NewDocumentFromReader(&md) if err != nil { return postSingleData{}, fmt.Errorf("failed to parse post %s: %w", post.Slug, err) } for _, processor := range b.postMDProcessors { if err := processor(b.site, dom); err != nil { return postSingleData{}, fmt.Errorf("failed to process post %s: %w", post.Slug, err) } } outHTML, err := dom.Find("body").Html() if err != nil { return postSingleData{}, fmt.Errorf("failed to render post %s: %w", post.Slug, err) } md.Reset() md.WriteString(outHTML) } 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 } // renderPostWithCategories renders a post and attaches its categories. func (b *Builder) renderPostWithCategories(ctx context.Context, post *models.Post) (postSingleData, error) { rp, err := b.renderPost(post) if err != nil { return postSingleData{}, err } if b.site.CategoriesOfPost != nil { cats, err := b.site.CategoriesOfPost(ctx, post.ID) if err != nil { return postSingleData{}, err } rp.Categories = cats } return rp, nil } func (b *Builder) renderCategoryList(ctx buildContext) error { var items []categoryListItem for _, cwc := range b.site.Categories { if cwc.PostCount == 0 { continue } items = append(items, categoryListItem{ CategoryWithCount: cwc, Path: fmt.Sprintf("/categories/%s", cwc.Slug), }) } if len(items) == 0 { return nil } data := categoryListData{ commonData: commonData{Site: b.site}, Categories: items, } return b.createAtPath(ctx, "/categories", func(f io.Writer) error { return b.renderTemplate(f, tmplNameCategoryList, data) }) } func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) error { for _, cwc := range b.site.Categories { if cwc.PostCount == 0 { continue } var posts []postSingleData for mp := range b.site.PostIterByCategory(goCtx, cwc.ID) { post, err := mp.Get() if err != nil { return err } rp, err := b.renderPostWithCategories(goCtx, post) if err != nil { return err } posts = append(posts, rp) } var descHTML bytes.Buffer if cwc.Description != "" { if err := b.mdRenderer.RenderTo(goCtx, &descHTML, cwc.Description); err != nil { return err } } data := categorySingleData{ commonData: commonData{Site: b.site}, Category: &cwc.Category, DescriptionHTML: template.HTML(descHTML.String()), Posts: posts, Path: fmt.Sprintf("/categories/%s", cwc.Slug), } if err := b.createAtPath(ctx, data.Path, func(f io.Writer) error { return b.renderTemplate(f, tmplNameCategorySingle, data) }); err != nil { return err } // Per-category feeds if err := b.renderCategoryFeed(ctx, cwc, posts); err != nil { return err } } return nil } func (b *Builder) renderCategoryFeed(ctx buildContext, cwc models.CategoryWithCount, posts []postSingleData) error { now := time.Now() feed := &feedhub.Feed{ Title: b.site.Title + " - " + cwc.Name, Link: &feedhub.Link{Href: b.site.BaseURL}, Description: cwc.DescriptionBrief, Created: now, } for i, rp := range posts { if i >= b.opts.FeedItems { break } feed.Items = append(feed.Items, &feedhub.Item{ Id: filepath.Join(b.site.BaseURL, rp.Post.GUID), Title: rp.Post.Title, Link: &feedhub.Link{Href: rp.PostURL}, Content: string(rp.HTML), Created: rp.Post.PublishedAt, Updated: rp.Post.UpdatedAt, }) } prefix := fmt.Sprintf("/categories/%s/feed", cwc.Slug) if err := b.createAtPath(ctx, prefix+".xml", func(f io.Writer) error { rss, err := feed.ToRss() if err != nil { return err } _, err = io.WriteString(f, rss) return err }); err != nil { return err } return b.createAtPath(ctx, prefix+".json", func(f io.Writer) error { j, err := feed.ToJSON() if err != nil { return err } _, err = io.WriteString(f, j) return err }) } 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 }