Add categories feature #3
9
layouts/simplecss/categories_list.html
Normal file
9
layouts/simplecss/categories_list.html
Normal 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>
|
||||
16
layouts/simplecss/categories_single.html
Normal file
16
layouts/simplecss/categories_single.html
Normal 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 }}
|
||||
|
|
@ -2,4 +2,11 @@
|
|||
{{ 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 }}
|
||||
|
|
@ -1,3 +1,10 @@
|
|||
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
|
||||
{{ .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 }}
|
||||
|
|
@ -11,11 +11,11 @@ import (
|
|||
type Site struct {
|
||||
models.Site
|
||||
BaseURL string
|
||||
//Posts []*models.Post
|
||||
Uploads []models.Upload
|
||||
|
||||
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]]
|
||||
OpenUpload func(u models.Upload) (io.ReadCloser, error)
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ type Builder struct {
|
|||
func New(site pubmodel.Site, opts Options) (*Builder, error) {
|
||||
tmpls, err := template.New("").
|
||||
Funcs(templateFns(site, opts)).
|
||||
ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain)
|
||||
ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain, tmplNameCategoryList, tmplNameCategorySingle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -62,7 +62,13 @@ func (b *Builder) BuildSite(outDir string) error {
|
|||
if err != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -70,10 +76,7 @@ func (b *Builder) BuildSite(outDir string) error {
|
|||
})
|
||||
|
||||
eg.Go(func() error {
|
||||
if err := b.renderPostList(buildCtx, b.site.PostIter(ctx)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return b.renderPostListWithCategories(buildCtx, ctx)
|
||||
})
|
||||
|
||||
eg.Go(func() error {
|
||||
|
|
@ -93,43 +96,42 @@ func (b *Builder) BuildSite(outDir string) error {
|
|||
return nil
|
||||
})
|
||||
|
||||
// Copy uploads
|
||||
// Category pages
|
||||
eg.Go(func() error {
|
||||
if err := b.writeUploads(buildCtx, b.site.Uploads); err != nil {
|
||||
if err := b.renderCategoryList(buildCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return b.renderCategoryPages(buildCtx, ctx)
|
||||
})
|
||||
if err := eg.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
// Copy uploads
|
||||
eg.Go(func() error {
|
||||
return b.writeUploads(buildCtx, b.site.Uploads)
|
||||
})
|
||||
|
||||
return eg.Wait()
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
postCopy = append(postCopy, post)
|
||||
rp, err := b.renderPostWithCategories(ctx, post)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
posts = append(posts, rp)
|
||||
}
|
||||
|
||||
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)
|
||||
Posts: posts,
|
||||
}
|
||||
|
||||
return b.createAtPath(ctx, "", func(f io.Writer) error {
|
||||
return b.createAtPath(bctx, "", func(f io.Writer) error {
|
||||
return b.renderTemplate(f, tmplNamePostList, pl)
|
||||
})
|
||||
}
|
||||
|
|
@ -156,16 +158,29 @@ func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[*
|
|||
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),
|
||||
Id: filepath.Join(b.site.BaseURL, post.GUID),
|
||||
Title: postTitle,
|
||||
Link: &feedhub.Link{Href: renderedPost.PostURL},
|
||||
Content: string(renderedPost.HTML),
|
||||
Category: catName,
|
||||
// TO FIX: Created should be first published
|
||||
Created: post.PublishedAt,
|
||||
Updated: post.UpdatedAt,
|
||||
|
|
@ -243,14 +258,142 @@ func (b *Builder) renderPost(post *models.Post) (postSingleData, error) {
|
|||
}, 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)
|
||||
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, rp.Path, func(f io.Writer) error {
|
||||
return b.renderTemplate(f, tmplNamePostSingle, rp)
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package sitebuilder_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"iter"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
|
@ -15,24 +17,36 @@ import (
|
|||
func TestBuilder_BuildSite(t *testing.T) {
|
||||
t.Run("build site", func(t *testing.T) {
|
||||
tmpls := fstest.MapFS{
|
||||
"posts_single.html": {Data: []byte(`{{ .HTML }}`)},
|
||||
"posts_list.html": {Data: []byte(`{{ range .Posts}}<a href="{{url_abs .Path}}">{{.Post.Title}}</a>,{{ end }}`)},
|
||||
"layout_main.html": {Data: []byte(`{{ .Body }}`)},
|
||||
"posts_single.html": {Data: []byte(`{{ .HTML }}`)},
|
||||
"posts_list.html": {Data: []byte(`{{ range .Posts}}<a href="{{url_abs .Path}}">{{.Post.Title}}</a>,{{ end }}`)},
|
||||
"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>`)},
|
||||
}
|
||||
|
||||
posts := []*models.Post{
|
||||
{
|
||||
Title: "Test Post",
|
||||
Slug: "/2026/02/18/test-post",
|
||||
Body: "This is a test post",
|
||||
},
|
||||
{
|
||||
Title: "Another Post",
|
||||
Slug: "/2026/02/20/another-post",
|
||||
Body: "This is **another** test post",
|
||||
},
|
||||
}
|
||||
|
||||
site := pubmodel.Site{
|
||||
BaseURL: "https://example.com",
|
||||
Posts: []*models.Post{
|
||||
{
|
||||
Title: "Test Post",
|
||||
Slug: "/2026/02/18/test-post",
|
||||
Body: "This is a test post",
|
||||
},
|
||||
{
|
||||
Title: "Another Post",
|
||||
Slug: "/2026/02/20/another-post",
|
||||
Body: "This is **another** test post",
|
||||
},
|
||||
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{
|
||||
|
|
@ -58,5 +72,4 @@ func TestBuilder_BuildSite(t *testing.T) {
|
|||
assert.Equal(t, content, string(fileContent))
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@ const (
|
|||
|
||||
// tmplNameLayoutMain is the template for the main layout (layoutMainData)
|
||||
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 {
|
||||
|
|
@ -41,10 +47,11 @@ type commonData struct {
|
|||
|
||||
type postSingleData struct {
|
||||
commonData
|
||||
Post *models.Post
|
||||
HTML template.HTML
|
||||
Path string
|
||||
PostURL string
|
||||
Post *models.Post
|
||||
HTML template.HTML
|
||||
Path string
|
||||
PostURL string
|
||||
Categories []*models.Category
|
||||
}
|
||||
|
||||
type postListData struct {
|
||||
|
|
@ -56,3 +63,21 @@ type layoutData struct {
|
|||
commonData
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"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]] {
|
||||
return func(yield func(models.Maybe[*models.Post]) bool) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,24 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error {
|
|||
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 {
|
||||
if !target.Enabled {
|
||||
continue
|
||||
|
|
@ -56,8 +74,15 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error {
|
|||
PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] {
|
||||
return p.postIter(ctx, site.ID)
|
||||
},
|
||||
BaseURL: target.BaseURL,
|
||||
Uploads: uploads,
|
||||
BaseURL: target.BaseURL,
|
||||
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) {
|
||||
return p.up.OpenUpload(site, u)
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue