2026-02-18 11:07:18 +00:00
|
|
|
package sitebuilder
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
2026-03-04 11:33:39 +00:00
|
|
|
"context"
|
2026-02-18 11:07:18 +00:00
|
|
|
"fmt"
|
|
|
|
|
"html/template"
|
|
|
|
|
"io"
|
2026-03-21 23:28:33 +00:00
|
|
|
"io/fs"
|
2026-03-05 11:04:24 +00:00
|
|
|
"iter"
|
2026-03-21 23:28:33 +00:00
|
|
|
"log"
|
2026-02-18 11:07:18 +00:00
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
2026-03-05 11:04:24 +00:00
|
|
|
"time"
|
2026-02-18 11:07:18 +00:00
|
|
|
|
2026-03-08 22:32:32 +00:00
|
|
|
"github.com/PuerkitoBio/goquery"
|
2026-03-05 11:04:24 +00:00
|
|
|
"github.com/gopherlibs/feedhub/feedhub"
|
2026-03-03 11:36:24 +00:00
|
|
|
"golang.org/x/sync/errgroup"
|
2026-02-18 11:07:18 +00:00
|
|
|
"lmika.dev/lmika/weiro/models"
|
2026-02-19 10:21:27 +00:00
|
|
|
"lmika.dev/lmika/weiro/models/pubmodel"
|
2026-03-04 11:33:39 +00:00
|
|
|
"lmika.dev/lmika/weiro/providers/markdown"
|
2026-02-18 11:07:18 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type Builder struct {
|
2026-03-08 22:32:32 +00:00
|
|
|
site pubmodel.Site
|
|
|
|
|
mdRenderer *markdown.Renderer
|
|
|
|
|
opts Options
|
|
|
|
|
tmpls *template.Template
|
|
|
|
|
postMDProcessors []postMDProcessor
|
2026-02-18 11:07:18 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 10:21:27 +00:00
|
|
|
func New(site pubmodel.Site, opts Options) (*Builder, error) {
|
2026-02-18 11:07:18 +00:00
|
|
|
tmpls, err := template.New("").
|
|
|
|
|
Funcs(templateFns(site, opts)).
|
2026-03-21 23:28:33 +00:00
|
|
|
ParseFS(opts.TemplatesFS, "*.html")
|
2026-02-18 11:07:18 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 23:28:33 +00:00
|
|
|
for _, t := range tmpls.Templates() {
|
|
|
|
|
log.Printf("Loaded template %s", t.Name())
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 11:07:18 +00:00
|
|
|
return &Builder{
|
2026-03-04 11:33:39 +00:00
|
|
|
site: site,
|
|
|
|
|
opts: opts,
|
|
|
|
|
tmpls: tmpls,
|
|
|
|
|
mdRenderer: markdown.NewRendererForSite(),
|
2026-03-08 22:32:32 +00:00
|
|
|
postMDProcessors: []postMDProcessor{
|
|
|
|
|
uploadAbsoluteURL,
|
|
|
|
|
},
|
2026-02-18 11:07:18 +00:00
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (b *Builder) BuildSite(outDir string) error {
|
|
|
|
|
buildCtx := buildContext{outDir: outDir}
|
|
|
|
|
|
|
|
|
|
if err := os.RemoveAll(outDir); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 11:04:24 +00:00
|
|
|
eg, ctx := errgroup.WithContext(context.Background())
|
2026-03-03 11:36:24 +00:00
|
|
|
|
|
|
|
|
eg.Go(func() error {
|
2026-03-05 11:04:24 +00:00
|
|
|
for mp := range b.site.PostIter(ctx) {
|
|
|
|
|
post, err := mp.Get()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-03-18 10:51:19 +00:00
|
|
|
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 {
|
2026-03-03 11:36:24 +00:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
eg.Go(func() error {
|
2026-03-18 10:51:19 +00:00
|
|
|
return b.renderPostListWithCategories(buildCtx, ctx)
|
2026-03-05 11:04:24 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
eg.Go(func() error {
|
2026-03-07 22:37:49 +00:00
|
|
|
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 {
|
2026-02-18 11:07:18 +00:00
|
|
|
return err
|
|
|
|
|
}
|
2026-03-03 11:36:24 +00:00
|
|
|
return nil
|
|
|
|
|
})
|
2026-02-18 11:07:18 +00:00
|
|
|
|
2026-03-18 10:51:19 +00:00
|
|
|
// Category pages
|
2026-03-03 11:36:24 +00:00
|
|
|
eg.Go(func() error {
|
2026-03-18 10:51:19 +00:00
|
|
|
if err := b.renderCategoryList(buildCtx); err != nil {
|
2026-03-03 11:36:24 +00:00
|
|
|
return err
|
|
|
|
|
}
|
2026-03-18 10:51:19 +00:00
|
|
|
return b.renderCategoryPages(buildCtx, ctx)
|
2026-03-03 11:36:24 +00:00
|
|
|
})
|
2026-02-18 11:07:18 +00:00
|
|
|
|
2026-03-18 10:51:19 +00:00
|
|
|
// Copy uploads
|
|
|
|
|
eg.Go(func() error {
|
|
|
|
|
return b.writeUploads(buildCtx, b.site.Uploads)
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-21 23:28:33 +00:00
|
|
|
// Build static assets
|
|
|
|
|
eg.Go(func() error { return b.writeStaticAssets(buildCtx) })
|
|
|
|
|
|
2026-03-22 08:11:12 +00:00
|
|
|
if err := eg.Wait(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Render pages last so they can override auto-generated content
|
|
|
|
|
return b.renderPages(buildCtx)
|
2026-02-18 11:07:18 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-18 10:51:19 +00:00
|
|
|
func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Context) error {
|
2026-03-22 03:37:42 +00:00
|
|
|
// Collect all posts
|
|
|
|
|
var allPosts []postSingleData
|
2026-03-18 10:51:19 +00:00
|
|
|
for mp := range b.site.PostIter(ctx) {
|
2026-03-05 11:04:24 +00:00
|
|
|
post, err := mp.Get()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-03-18 10:51:19 +00:00
|
|
|
rp, err := b.renderPostWithCategories(ctx, post)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-03-22 03:37:42 +00:00
|
|
|
allPosts = append(allPosts, rp)
|
2026-03-05 11:04:24 +00:00
|
|
|
}
|
2026-02-18 11:07:18 +00:00
|
|
|
|
2026-03-22 03:37:42 +00:00
|
|
|
postsPerPage := b.site.PostsPerPage
|
|
|
|
|
if postsPerPage < 1 {
|
|
|
|
|
postsPerPage = 10
|
2026-02-18 11:07:18 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-22 03:37:42 +00:00
|
|
|
totalPages := (len(allPosts) + postsPerPage - 1) / postsPerPage
|
|
|
|
|
if totalPages < 1 {
|
|
|
|
|
totalPages = 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for page := 1; page <= totalPages; page++ {
|
|
|
|
|
start := (page - 1) * postsPerPage
|
|
|
|
|
end := start + postsPerPage
|
|
|
|
|
if end > len(allPosts) {
|
|
|
|
|
end = len(allPosts)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pageInfo := models.PageInfo{
|
|
|
|
|
CurrentPage: page,
|
|
|
|
|
TotalPages: totalPages,
|
|
|
|
|
PostsPerPage: postsPerPage,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var prevURL, nextURL string
|
|
|
|
|
if page > 1 {
|
2026-03-23 10:48:43 +00:00
|
|
|
prevURL = fmt.Sprintf("%v/%d", b.opts.BasePostList, page-1)
|
2026-03-22 03:37:42 +00:00
|
|
|
}
|
|
|
|
|
if page < totalPages {
|
2026-03-23 10:48:43 +00:00
|
|
|
nextURL = fmt.Sprintf("%v/%d", b.opts.BasePostList, page+1)
|
2026-03-22 03:37:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pl := postListData{
|
|
|
|
|
commonData: commonData{Site: b.site},
|
|
|
|
|
Posts: allPosts[start:end],
|
|
|
|
|
PageInfo: pageInfo,
|
|
|
|
|
PrevURL: prevURL,
|
|
|
|
|
NextURL: nextURL,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Page 1 renders at both root and /posts/
|
|
|
|
|
var paths []string
|
|
|
|
|
if page == 1 {
|
2026-03-23 10:48:43 +00:00
|
|
|
paths = []string{"", fmt.Sprintf("%v/1", b.opts.BasePostList)}
|
2026-03-22 03:37:42 +00:00
|
|
|
} else {
|
2026-03-23 10:48:43 +00:00
|
|
|
paths = []string{fmt.Sprintf("%v/%d", b.opts.BasePostList, page)}
|
2026-03-22 03:37:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, path := range paths {
|
|
|
|
|
if err := b.createAtPath(bctx, path, func(f io.Writer) error {
|
|
|
|
|
return b.renderTemplate(f, tmplNamePostList, pl)
|
|
|
|
|
}); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
2026-02-18 11:07:18 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-07 22:37:49 +00:00
|
|
|
func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[*models.Post]], opts feedOptions) error {
|
2026-03-05 11:04:24 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 10:51:19 +00:00
|
|
|
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, ", ")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 22:37:49 +00:00
|
|
|
postTitle := post.Title
|
|
|
|
|
if postTitle != "" {
|
|
|
|
|
postTitle = opts.titlePrefix + postTitle
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 11:04:24 +00:00
|
|
|
feed.Items = append(feed.Items, &feedhub.Item{
|
2026-03-21 01:01:24 +00:00
|
|
|
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?
|
2026-03-18 10:51:19 +00:00
|
|
|
Category: catName,
|
2026-03-05 11:04:24 +00:00
|
|
|
// TO FIX: Created should be first published
|
|
|
|
|
Created: post.PublishedAt,
|
2026-03-05 11:48:38 +00:00
|
|
|
Updated: post.UpdatedAt,
|
2026-03-05 11:04:24 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
items++
|
|
|
|
|
if items >= b.opts.FeedItems {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 22:37:49 +00:00
|
|
|
if err := b.createAtPath(ctx, opts.targetNamePrefix+".xml", func(f io.Writer) error {
|
2026-03-05 11:04:24 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 22:37:49 +00:00
|
|
|
if err := b.createAtPath(ctx, opts.targetNamePrefix+".json", func(f io.Writer) error {
|
2026-03-05 11:04:24 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 11:07:18 +00:00
|
|
|
func (b *Builder) renderPost(post *models.Post) (postSingleData, error) {
|
2026-02-19 10:21:27 +00:00
|
|
|
postPath := post.Slug
|
2026-02-18 11:07:18 +00:00
|
|
|
if b.opts.BasePosts != "" {
|
|
|
|
|
postPath = filepath.Join(b.opts.BasePosts, strings.TrimPrefix(postPath, "/"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var md bytes.Buffer
|
2026-03-04 11:33:39 +00:00
|
|
|
if err := b.mdRenderer.RenderTo(context.Background(), &md, post.Body); err != nil {
|
2026-02-19 10:21:27 +00:00
|
|
|
return postSingleData{}, fmt.Errorf("failed to write post %s: %w", post.Slug, err)
|
2026-02-18 11:07:18 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-08 22:32:32 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 11:04:24 +00:00
|
|
|
postURL := strings.TrimSuffix(b.site.BaseURL, "/") + "/" + strings.TrimPrefix(postPath, "/")
|
|
|
|
|
|
2026-02-18 11:07:18 +00:00
|
|
|
return postSingleData{
|
2026-02-19 10:21:27 +00:00
|
|
|
commonData: commonData{Site: b.site},
|
2026-02-18 11:38:05 +00:00
|
|
|
Path: postPath,
|
2026-02-19 11:29:44 +00:00
|
|
|
Post: post,
|
2026-03-05 11:04:24 +00:00
|
|
|
PostURL: postURL,
|
2026-02-18 11:38:05 +00:00
|
|
|
HTML: template.HTML(md.String()),
|
2026-02-18 11:07:18 +00:00
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 10:51:19 +00:00
|
|
|
// renderPostWithCategories renders a post and attaches its categories.
|
|
|
|
|
func (b *Builder) renderPostWithCategories(ctx context.Context, post *models.Post) (postSingleData, error) {
|
2026-02-18 11:07:18 +00:00
|
|
|
rp, err := b.renderPost(post)
|
|
|
|
|
if err != nil {
|
2026-03-18 10:51:19 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 03:38:39 +00:00
|
|
|
// Collect all posts for this category
|
|
|
|
|
var allPosts []postSingleData
|
2026-03-18 10:51:19 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-03-22 03:38:39 +00:00
|
|
|
allPosts = append(allPosts, rp)
|
2026-03-18 10:51:19 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var descHTML bytes.Buffer
|
|
|
|
|
if cwc.Description != "" {
|
|
|
|
|
if err := b.mdRenderer.RenderTo(goCtx, &descHTML, cwc.Description); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 03:38:39 +00:00
|
|
|
postsPerPage := b.site.PostsPerPage
|
|
|
|
|
if postsPerPage < 1 {
|
|
|
|
|
postsPerPage = 10
|
2026-03-18 10:51:19 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-22 03:38:39 +00:00
|
|
|
totalPages := (len(allPosts) + postsPerPage - 1) / postsPerPage
|
|
|
|
|
if totalPages < 1 {
|
|
|
|
|
totalPages = 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
basePath := fmt.Sprintf("/categories/%s", cwc.Slug)
|
|
|
|
|
|
|
|
|
|
for page := 1; page <= totalPages; page++ {
|
|
|
|
|
start := (page - 1) * postsPerPage
|
|
|
|
|
end := start + postsPerPage
|
|
|
|
|
if end > len(allPosts) {
|
|
|
|
|
end = len(allPosts)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pageInfo := models.PageInfo{
|
|
|
|
|
CurrentPage: page,
|
|
|
|
|
TotalPages: totalPages,
|
|
|
|
|
PostsPerPage: postsPerPage,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var prevURL, nextURL string
|
|
|
|
|
if page > 1 {
|
|
|
|
|
if page == 2 {
|
|
|
|
|
prevURL = basePath + "/"
|
|
|
|
|
} else {
|
2026-03-22 05:22:32 +00:00
|
|
|
prevURL = fmt.Sprintf("%s/%d/", basePath, page-1)
|
2026-03-22 03:38:39 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if page < totalPages {
|
2026-03-22 05:22:32 +00:00
|
|
|
nextURL = fmt.Sprintf("%s/%d/", basePath, page+1)
|
2026-03-22 03:38:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
path := basePath
|
|
|
|
|
if page > 1 {
|
2026-03-22 05:22:32 +00:00
|
|
|
path = fmt.Sprintf("%s/%d", basePath, page)
|
2026-03-22 03:38:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data := categorySingleData{
|
|
|
|
|
commonData: commonData{Site: b.site},
|
|
|
|
|
Category: &cwc.Category,
|
|
|
|
|
DescriptionHTML: template.HTML(descHTML.String()),
|
|
|
|
|
Posts: allPosts[start:end],
|
|
|
|
|
Path: path,
|
|
|
|
|
PageInfo: pageInfo,
|
|
|
|
|
PrevURL: prevURL,
|
|
|
|
|
NextURL: nextURL,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := b.createAtPath(ctx, path, func(f io.Writer) error {
|
|
|
|
|
return b.renderTemplate(f, tmplNameCategorySingle, data)
|
|
|
|
|
}); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
2026-03-18 10:51:19 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-22 03:38:39 +00:00
|
|
|
// Per-category feeds (use all posts, not paginated)
|
|
|
|
|
if err := b.renderCategoryFeed(ctx, cwc, allPosts); err != nil {
|
2026-03-18 10:51:19 +00:00
|
|
|
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 {
|
2026-02-18 11:07:18 +00:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 10:51:19 +00:00
|
|
|
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
|
2026-02-18 11:07:18 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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{
|
2026-02-19 10:21:27 +00:00
|
|
|
commonData: commonData{Site: b.site},
|
2026-02-18 11:38:05 +00:00
|
|
|
Body: template.HTML(buf.String()),
|
2026-02-18 11:07:18 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 11:36:24 +00:00
|
|
|
func (b *Builder) writeUploads(ctx buildContext, uploads []models.Upload) error {
|
|
|
|
|
for _, u := range uploads {
|
2026-03-21 23:28:33 +00:00
|
|
|
fullPath := filepath.Join(ctx.outDir, b.opts.BaseUploads, u.Slug)
|
2026-03-03 11:36:24 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-03-21 23:28:33 +00:00
|
|
|
|
|
|
|
|
func (b *Builder) writeStaticAssets(ctx buildContext) error {
|
2026-03-22 03:41:50 +00:00
|
|
|
if b.opts.StaticFS == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-03-21 23:28:33 +00:00
|
|
|
return fs.WalkDir(b.opts.StaticFS, ".", func(path string, d os.DirEntry, err error) error {
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
} else if d.IsDir() {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fullPath := filepath.Join(ctx.outDir, b.opts.BaseStatic, path)
|
|
|
|
|
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return func() error {
|
|
|
|
|
r, err := b.opts.StaticFS.Open(path)
|
|
|
|
|
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
|
|
|
|
|
}()
|
|
|
|
|
})
|
|
|
|
|
}
|