feat: add category pages and per-category feeds to site builder

Extend the publishing pipeline to generate category index pages,
per-category archive pages, per-category RSS/JSON feeds, and display
categories on individual post pages and post lists.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Leon Mika 2026-03-18 21:51:19 +11:00
parent 4c2ce7272d
commit 6c69131b03
10 changed files with 329 additions and 61 deletions

View file

@ -0,0 +1,9 @@
<h2>Categories</h2>
<ul>
{{ range .Categories }}
<li>
<a href="{{ url_abs .Path }}">{{ .Name }}</a> ({{ .PostCount }})
{{ if .DescriptionBrief }}<br><small>{{ .DescriptionBrief }}</small>{{ end }}
</li>
{{ end }}
</ul>

View file

@ -0,0 +1,16 @@
<h2>{{ .Category.Name }}</h2>
{{ if .DescriptionHTML }}
<div>{{ .DescriptionHTML }}</div>
{{ end }}
{{ range .Posts }}
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
{{ .HTML }}
<a href="{{ url_abs .Path }}">{{ format_date .Post.PublishedAt }}</a>
{{ if .Categories }}
<p>
{{ range .Categories }}
<a href="{{ url_abs (printf "/categories/%s" .Slug) }}">{{ .Name }}</a>
{{ end }}
</p>
{{ end }}
{{ end }}

View file

@ -2,4 +2,11 @@
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }} {{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
{{ .HTML }} {{ .HTML }}
<a href="{{ url_abs .Path }}">{{ format_date .Post.PublishedAt }}</a> <a href="{{ url_abs .Path }}">{{ format_date .Post.PublishedAt }}</a>
{{ if .Categories }}
<p>
{{ range .Categories }}
<a href="{{ url_abs (printf "/categories/%s" .Slug) }}">{{ .Name }}</a>
{{ end }}
</p>
{{ end }}
{{ end }} {{ end }}

View file

@ -1,3 +1,10 @@
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }} {{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
{{ .HTML }} {{ .HTML }}
<a href="{{ url_abs .Path }}">{{ format_date .Post.PublishedAt }}</a> <a href="{{ url_abs .Path }}">{{ format_date .Post.PublishedAt }}</a>
{{ if .Categories }}
<p>
{{ range .Categories }}
<a href="{{ url_abs (printf "/categories/%s" .Slug) }}">{{ .Name }}</a>
{{ end }}
</p>
{{ end }}

View file

@ -11,11 +11,11 @@ import (
type Site struct { type Site struct {
models.Site models.Site
BaseURL string BaseURL string
//Posts []*models.Post
Uploads []models.Upload Uploads []models.Upload
OpenUpload func(u models.Upload) (io.ReadCloser, error) OpenUpload func(u models.Upload) (io.ReadCloser, error)
// PostItr returns a new post iterator
PostIter func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] PostIter func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]]
Categories []models.CategoryWithCount
PostIterByCategory func(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]]
CategoriesOfPost func(ctx context.Context, postID int64) ([]*models.Category, error)
} }

View file

@ -31,7 +31,7 @@ type Builder struct {
func New(site pubmodel.Site, opts Options) (*Builder, error) { func New(site pubmodel.Site, opts Options) (*Builder, error) {
tmpls, err := template.New(""). tmpls, err := template.New("").
Funcs(templateFns(site, opts)). Funcs(templateFns(site, opts)).
ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain) ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain, tmplNameCategoryList, tmplNameCategorySingle)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -62,7 +62,13 @@ func (b *Builder) BuildSite(outDir string) error {
if err != nil { if err != nil {
return err return err
} }
if err := b.writePost(buildCtx, post); err != nil { 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 err
} }
} }
@ -70,10 +76,7 @@ func (b *Builder) BuildSite(outDir string) error {
}) })
eg.Go(func() error { eg.Go(func() error {
if err := b.renderPostList(buildCtx, b.site.PostIter(ctx)); err != nil { return b.renderPostListWithCategories(buildCtx, ctx)
return err
}
return nil
}) })
eg.Go(func() error { eg.Go(func() error {
@ -93,43 +96,42 @@ func (b *Builder) BuildSite(outDir string) error {
return nil return nil
}) })
// Category pages
eg.Go(func() error {
if err := b.renderCategoryList(buildCtx); err != nil {
return err
}
return b.renderCategoryPages(buildCtx, ctx)
})
// Copy uploads // Copy uploads
eg.Go(func() error { eg.Go(func() error {
if err := b.writeUploads(buildCtx, b.site.Uploads); err != nil { return b.writeUploads(buildCtx, b.site.Uploads)
return err
}
return nil
}) })
if err := eg.Wait(); err != nil {
return err
}
return nil return eg.Wait()
} }
func (b *Builder) renderPostList(ctx buildContext, postIter iter.Seq[models.Maybe[*models.Post]]) error { func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Context) error {
// TODO: paging var posts []postSingleData
postCopy := make([]*models.Post, 0) for mp := range b.site.PostIter(ctx) {
for mp := range postIter {
post, err := mp.Get() post, err := mp.Get()
if err != nil { if err != nil {
return err return err
} }
postCopy = append(postCopy, post) rp, err := b.renderPostWithCategories(ctx, post)
if err != nil {
return err
}
posts = append(posts, rp)
} }
pl := postListData{ pl := postListData{
commonData: commonData{Site: b.site}, commonData: commonData{Site: b.site},
} Posts: posts,
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.createAtPath(bctx, "", func(f io.Writer) error {
return b.renderTemplate(f, tmplNamePostList, pl) return b.renderTemplate(f, tmplNamePostList, pl)
}) })
} }
@ -156,6 +158,18 @@ func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[*
return err 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 postTitle := post.Title
if postTitle != "" { if postTitle != "" {
postTitle = opts.titlePrefix + postTitle postTitle = opts.titlePrefix + postTitle
@ -166,6 +180,7 @@ func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[*
Title: postTitle, Title: postTitle,
Link: &feedhub.Link{Href: renderedPost.PostURL}, Link: &feedhub.Link{Href: renderedPost.PostURL},
Content: string(renderedPost.HTML), Content: string(renderedPost.HTML),
Category: catName,
// TO FIX: Created should be first published // TO FIX: Created should be first published
Created: post.PublishedAt, Created: post.PublishedAt,
Updated: post.UpdatedAt, Updated: post.UpdatedAt,
@ -243,14 +258,142 @@ func (b *Builder) renderPost(post *models.Post) (postSingleData, error) {
}, nil }, nil
} }
func (b *Builder) writePost(ctx buildContext, post *models.Post) error { // 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) 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 { if err != nil {
return err return err
} }
rp, err := b.renderPostWithCategories(goCtx, post)
if err != nil {
return err
}
posts = append(posts, rp)
}
return b.createAtPath(ctx, rp.Path, func(f io.Writer) error { var descHTML bytes.Buffer
return b.renderTemplate(f, tmplNamePostSingle, rp) 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
}) })
} }

View file

@ -1,6 +1,8 @@
package sitebuilder_test package sitebuilder_test
import ( import (
"context"
"iter"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@ -18,11 +20,11 @@ func TestBuilder_BuildSite(t *testing.T) {
"posts_single.html": {Data: []byte(`{{ .HTML }}`)}, "posts_single.html": {Data: []byte(`{{ .HTML }}`)},
"posts_list.html": {Data: []byte(`{{ range .Posts}}<a href="{{url_abs .Path}}">{{.Post.Title}}</a>,{{ end }}`)}, "posts_list.html": {Data: []byte(`{{ range .Posts}}<a href="{{url_abs .Path}}">{{.Post.Title}}</a>,{{ end }}`)},
"layout_main.html": {Data: []byte(`{{ .Body }}`)}, "layout_main.html": {Data: []byte(`{{ .Body }}`)},
"categories_list.html": {Data: []byte(`{{ range .Categories}}<a href="{{url_abs .Path}}">{{.Name}}</a>,{{ end }}`)},
"categories_single.html": {Data: []byte(`<h2>{{.Category.Name}}</h2>`)},
} }
site := pubmodel.Site{ posts := []*models.Post{
BaseURL: "https://example.com",
Posts: []*models.Post{
{ {
Title: "Test Post", Title: "Test Post",
Slug: "/2026/02/18/test-post", Slug: "/2026/02/18/test-post",
@ -33,6 +35,18 @@ func TestBuilder_BuildSite(t *testing.T) {
Slug: "/2026/02/20/another-post", Slug: "/2026/02/20/another-post",
Body: "This is **another** test post", Body: "This is **another** test post",
}, },
}
site := pubmodel.Site{
BaseURL: "https://example.com",
PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] {
return func(yield func(models.Maybe[*models.Post]) bool) {
for _, p := range posts {
if !yield(models.Maybe[*models.Post]{Value: p}) {
return
}
}
}
}, },
} }
wantFiles := map[string]string{ wantFiles := map[string]string{
@ -58,5 +72,4 @@ func TestBuilder_BuildSite(t *testing.T) {
assert.Equal(t, content, string(fileContent)) assert.Equal(t, content, string(fileContent))
} }
}) })
} }

View file

@ -20,6 +20,12 @@ const (
// tmplNameLayoutMain is the template for the main layout (layoutMainData) // tmplNameLayoutMain is the template for the main layout (layoutMainData)
tmplNameLayoutMain = "layout_main.html" tmplNameLayoutMain = "layout_main.html"
// tmplNameCategoryList is the template for the category index page
tmplNameCategoryList = "categories_list.html"
// tmplNameCategorySingle is the template for a single category page
tmplNameCategorySingle = "categories_single.html"
) )
type Options struct { type Options struct {
@ -45,6 +51,7 @@ type postSingleData struct {
HTML template.HTML HTML template.HTML
Path string Path string
PostURL string PostURL string
Categories []*models.Category
} }
type postListData struct { type postListData struct {
@ -56,3 +63,21 @@ type layoutData struct {
commonData commonData
Body template.HTML Body template.HTML
} }
type categoryListData struct {
commonData
Categories []categoryListItem
}
type categoryListItem struct {
models.CategoryWithCount
Path string
}
type categorySingleData struct {
commonData
Category *models.Category
DescriptionHTML template.HTML
Posts []postSingleData
Path string
}

View file

@ -8,7 +8,7 @@ import (
"lmika.dev/lmika/weiro/providers/db" "lmika.dev/lmika/weiro/providers/db"
) )
// PostIter returns a post iterator which returns posts in reverse chronological order. // postIter returns a post iterator which returns posts in reverse chronological order.
func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] { func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] {
return func(yield func(models.Maybe[*models.Post]) bool) { return func(yield func(models.Maybe[*models.Post]) bool) {
paging := db.PagingParams{Offset: 0, Limit: 50} paging := db.PagingParams{Offset: 0, Limit: 50}
@ -39,3 +39,26 @@ func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Ma
} }
} }
} }
// postIterByCategory returns a post iterator for posts in a specific category.
func (s *Publisher) postIterByCategory(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]] {
return func(yield func(models.Maybe[*models.Post]) bool) {
paging := db.PagingParams{Offset: 0, Limit: 50}
for {
page, err := s.db.SelectPostsOfCategory(ctx, categoryID, paging)
if err != nil {
yield(models.Maybe[*models.Post]{Err: err})
return
}
if len(page) == 0 {
return
}
for _, post := range page {
if !yield(models.Maybe[*models.Post]{Value: post}) {
return
}
}
paging.Offset += paging.Limit
}
}
}

View file

@ -46,6 +46,24 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error {
return err return err
} }
// Fetch categories with counts
cats, err := p.db.SelectCategoriesOfSite(ctx, site.ID)
if err != nil {
return err
}
var catsWithCounts []models.CategoryWithCount
for _, cat := range cats {
count, err := p.db.CountPostsOfCategory(ctx, cat.ID)
if err != nil {
return err
}
catsWithCounts = append(catsWithCounts, models.CategoryWithCount{
Category: *cat,
PostCount: int(count),
DescriptionBrief: models.BriefDescription(cat.Description),
})
}
for _, target := range targets { for _, target := range targets {
if !target.Enabled { if !target.Enabled {
continue continue
@ -58,6 +76,13 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error {
}, },
BaseURL: target.BaseURL, BaseURL: target.BaseURL,
Uploads: uploads, Uploads: uploads,
Categories: catsWithCounts,
PostIterByCategory: func(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]] {
return p.postIterByCategory(ctx, categoryID)
},
CategoriesOfPost: func(ctx context.Context, postID int64) ([]*models.Category, error) {
return p.db.SelectCategoriesOfPost(ctx, postID)
},
OpenUpload: func(u models.Upload) (io.ReadCloser, error) { OpenUpload: func(u models.Upload) (io.ReadCloser, error) {
return p.up.OpenUpload(site, u) return p.up.OpenUpload(site, u)
}, },