diff --git a/layouts/simplecss/categories_list.html b/layouts/simplecss/categories_list.html new file mode 100644 index 0000000..32331f6 --- /dev/null +++ b/layouts/simplecss/categories_list.html @@ -0,0 +1,9 @@ +

Categories

+ \ No newline at end of file diff --git a/layouts/simplecss/categories_single.html b/layouts/simplecss/categories_single.html new file mode 100644 index 0000000..e8d59d1 --- /dev/null +++ b/layouts/simplecss/categories_single.html @@ -0,0 +1,16 @@ +

{{ .Category.Name }}

+{{ if .DescriptionHTML }} +
{{ .DescriptionHTML }}
+{{ end }} +{{ range .Posts }} + {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} + {{ .HTML }} + {{ format_date .Post.PublishedAt }} + {{ if .Categories }} +

+ {{ range .Categories }} + {{ .Name }} + {{ end }} +

+ {{ end }} +{{ end }} \ No newline at end of file diff --git a/layouts/simplecss/posts_list.html b/layouts/simplecss/posts_list.html index 944c5a1..e6a77fe 100644 --- a/layouts/simplecss/posts_list.html +++ b/layouts/simplecss/posts_list.html @@ -2,4 +2,11 @@ {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} {{ .HTML }} {{ format_date .Post.PublishedAt }} + {{ if .Categories }} +

+ {{ range .Categories }} + {{ .Name }} + {{ end }} +

+ {{ end }} {{ end }} \ No newline at end of file diff --git a/layouts/simplecss/posts_single.html b/layouts/simplecss/posts_single.html index 5fd9fcb..cda9bb2 100644 --- a/layouts/simplecss/posts_single.html +++ b/layouts/simplecss/posts_single.html @@ -1,3 +1,10 @@ {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} {{ .HTML }} -{{ format_date .Post.PublishedAt }} \ No newline at end of file +{{ format_date .Post.PublishedAt }} +{{ if .Categories }} +

+ {{ range .Categories }} + {{ .Name }} + {{ end }} +

+{{ end }} \ No newline at end of file diff --git a/models/pubmodel/sites.go b/models/pubmodel/sites.go index a745885..a8862c4 100644 --- a/models/pubmodel/sites.go +++ b/models/pubmodel/sites.go @@ -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) } diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 4908d61..5775149 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -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 }) } diff --git a/providers/sitebuilder/builder_test.go b/providers/sitebuilder/builder_test.go index 2564f6d..cbe116b 100644 --- a/providers/sitebuilder/builder_test.go +++ b/providers/sitebuilder/builder_test.go @@ -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}}{{.Post.Title}},{{ end }}`)}, - "layout_main.html": {Data: []byte(`{{ .Body }}`)}, + "posts_single.html": {Data: []byte(`{{ .HTML }}`)}, + "posts_list.html": {Data: []byte(`{{ range .Posts}}{{.Post.Title}},{{ end }}`)}, + "layout_main.html": {Data: []byte(`{{ .Body }}`)}, + "categories_list.html": {Data: []byte(`{{ range .Categories}}{{.Name}},{{ end }}`)}, + "categories_single.html": {Data: []byte(`

{{.Category.Name}}

`)}, + } + + 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)) } }) - } diff --git a/providers/sitebuilder/tmpls.go b/providers/sitebuilder/tmpls.go index fa70b6d..2152290 100644 --- a/providers/sitebuilder/tmpls.go +++ b/providers/sitebuilder/tmpls.go @@ -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 +} diff --git a/services/publisher/iter.go b/services/publisher/iter.go index 48b5252..ea70616 100644 --- a/services/publisher/iter.go +++ b/services/publisher/iter.go @@ -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 + } + } +} diff --git a/services/publisher/service.go b/services/publisher/service.go index f0b39f0..2ed9046 100644 --- a/services/publisher/service.go +++ b/services/publisher/service.go @@ -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) },