From 41c8d1e2f5d6e70b4e454e6b2fb6f210484c03ee Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:29:11 +1100 Subject: [PATCH] Add categories implementation plan 9-task plan covering migration, sqlc queries, DB provider, service layer, admin UI, post form integration, site builder with category pages and per-category feeds. Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-18-categories.md | 2036 +++++++++++++++++ 1 file changed, 2036 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-18-categories.md diff --git a/docs/superpowers/plans/2026-03-18-categories.md b/docs/superpowers/plans/2026-03-18-categories.md new file mode 100644 index 0000000..b4f3932 --- /dev/null +++ b/docs/superpowers/plans/2026-03-18-categories.md @@ -0,0 +1,2036 @@ +# Categories Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add flat, many-to-many categories to Weiro with admin CRUD, post assignment, static site archive pages, and per-category feeds. + +**Architecture:** New `categories` and `post_categories` tables in SQLite. New sqlc queries, DB provider methods, a `categories` service, and a `CategoriesHandler`. The site builder gains category index/archive page rendering and per-category feeds. Posts carry category associations managed via a join table with delete-and-reinsert on save. + +**Tech Stack:** Go 1.25, SQLite (sqlc), Fiber v3, Go html/template, Bootstrap 5, Stimulus.js + +**Spec:** `docs/superpowers/specs/2026-03-18-categories-design.md` + +--- + +## File Map + +| Action | File | Responsibility | +|--------|------|---------------| +| Create | `models/categories.go` | Category model + slug generation | +| Create | `sql/schema/04_categories.up.sql` | Migration: categories + post_categories tables | +| Create | `sql/queries/categories.sql` | All sqlc queries for categories | +| Create | `providers/db/categories.go` | DB provider wrapper methods for categories | +| Create | `services/categories/service.go` | Category service: CRUD + slug validation | +| Create | `handlers/categories.go` | HTTP handlers for category admin pages | +| Create | `views/categories/index.html` | Admin: category list page | +| Create | `views/categories/edit.html` | Admin: category create/edit form | +| Create | `layouts/simplecss/categories_list.html` | Published site: category index page template | +| Create | `layouts/simplecss/categories_single.html` | Published site: category archive page template | +| Modify | `models/errors.go` | Add `SlugConflictError` | +| Modify | `providers/db/gen/sqlgen/*` | Regenerated by sqlc | +| Modify | `providers/db/posts.go` | Add `SelectCategoriesOfPost`, `SetPostCategories` | +| Modify | `providers/db/provider.go` | Expose `drvr` for transactions via `BeginTx` | +| Modify | `models/pubmodel/sites.go` | Add `Categories`, `PostIterByCategory` fields | +| Modify | `providers/sitebuilder/tmpls.go` | Add category template names + data structs | +| Modify | `providers/sitebuilder/builder.go` | Render category pages + per-category feeds | +| Modify | `services/posts/service.go` | Accept DB transaction support | +| Modify | `services/posts/create.go` | Save category associations in transaction | +| Modify | `services/publisher/service.go` | Populate category data on `pubmodel.Site` | +| Modify | `services/publisher/iter.go` | Add `postIterByCategory` method | +| Modify | `services/services.go` | Wire up categories service | +| Modify | `cmds/server.go` | Register category routes + handler | +| Modify | `views/posts/edit.html` | Add category sidebar with checkboxes | +| Modify | `views/posts/index.html` | Show category badges on post list | +| Modify | `views/_common/nav.html` | Add "Categories" nav link | + +--- + +## Task 1: Database Migration + Model + +**Files:** +- Create: `sql/schema/04_categories.up.sql` +- Create: `models/categories.go` +- Modify: `models/errors.go` + +- [ ] **Step 1: Create the migration file** + +Create `sql/schema/04_categories.up.sql`: + +```sql +CREATE TABLE categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id INTEGER NOT NULL, + guid TEXT NOT NULL, + name TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE +); +CREATE INDEX idx_categories_site ON categories (site_id); +CREATE UNIQUE INDEX idx_categories_guid ON categories (guid); +CREATE UNIQUE INDEX idx_categories_site_slug ON categories (site_id, slug); + +CREATE TABLE post_categories ( + post_id INTEGER NOT NULL, + category_id INTEGER NOT NULL, + PRIMARY KEY (post_id, category_id), + FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE +); +CREATE INDEX idx_post_categories_category ON post_categories (category_id); +``` + +- [ ] **Step 2: Create the Category model** + +Create `models/categories.go`: + +```go +package models + +import ( + "strings" + "time" + "unicode" +) + +type Category struct { + ID int64 `json:"id"` + SiteID int64 `json:"site_id"` + GUID string `json:"guid"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CategoryWithCount is a Category plus the count of published posts in it. +type CategoryWithCount struct { + Category + PostCount int + DescriptionBrief string +} + +// GenerateCategorySlug creates a URL-safe slug from a category name. +// e.g. "Go Programming" -> "go-programming" +func GenerateCategorySlug(name string) string { + var sb strings.Builder + prevDash := false + for _, c := range strings.TrimSpace(name) { + if unicode.IsLetter(c) || unicode.IsNumber(c) { + sb.WriteRune(unicode.ToLower(c)) + prevDash = false + } else if unicode.IsSpace(c) || c == '-' || c == '_' { + if !prevDash && sb.Len() > 0 { + sb.WriteRune('-') + prevDash = true + } + } + } + result := sb.String() + return strings.TrimRight(result, "-") +} +``` + +- [ ] **Step 3: Add SlugConflictError to models/errors.go** + +Add to `models/errors.go`: + +```go +var SlugConflictError = errors.New("a category with this slug already exists") +``` + +- [ ] **Step 4: Write a test for GenerateCategorySlug** + +Create `models/categories_test.go`: + +```go +package models_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "lmika.dev/lmika/weiro/models" +) + +func TestGenerateCategorySlug(t *testing.T) { + tests := []struct { + name string + want string + }{ + {"Go Programming", "go-programming"}, + {" Travel ", "travel"}, + {"hello---world", "hello-world"}, + {"UPPER CASE", "upper-case"}, + {"one", "one"}, + {"with_underscores", "with-underscores"}, + {"special!@#chars", "specialchars"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, models.GenerateCategorySlug(tt.name)) + }) + } +} +``` + +- [ ] **Step 5: Run the test** + +Run: `go test ./models/ -run TestGenerateCategorySlug -v` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add models/categories.go models/categories_test.go models/errors.go sql/schema/04_categories.up.sql +git commit -m "feat: add categories migration and model" +``` + +--- + +## Task 2: SQL Queries + sqlc Generation + +**Files:** +- Create: `sql/queries/categories.sql` +- Regenerate: `providers/db/gen/sqlgen/*` + +- [ ] **Step 1: Create the sqlc queries file** + +Create `sql/queries/categories.sql`: + +```sql +-- name: SelectCategoriesOfSite :many +SELECT * FROM categories +WHERE site_id = ? ORDER BY name ASC; + +-- name: SelectCategory :one +SELECT * FROM categories WHERE id = ? LIMIT 1; + +-- name: SelectCategoryByGUID :one +SELECT * FROM categories WHERE guid = ? LIMIT 1; + +-- name: SelectCategoryBySlugAndSite :one +SELECT * FROM categories WHERE site_id = ? AND slug = ? LIMIT 1; + +-- name: SelectCategoriesOfPost :many +SELECT c.* FROM categories c +INNER JOIN post_categories pc ON pc.category_id = c.id +WHERE pc.post_id = ? +ORDER BY c.name ASC; + +-- name: SelectPostsOfCategory :many +SELECT p.* FROM posts p +INNER JOIN post_categories pc ON pc.post_id = p.id +WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0 +ORDER BY p.published_at DESC +LIMIT ? OFFSET ?; + +-- name: CountPostsOfCategory :one +SELECT COUNT(*) FROM posts p +INNER JOIN post_categories pc ON pc.post_id = p.id +WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0; + +-- name: InsertCategory :one +INSERT INTO categories ( + site_id, guid, name, slug, description, created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?) +RETURNING id; + +-- name: UpdateCategory :exec +UPDATE categories SET + name = ?, + slug = ?, + description = ?, + updated_at = ? +WHERE id = ?; + +-- name: DeleteCategory :exec +DELETE FROM categories WHERE id = ?; + +-- name: InsertPostCategory :exec +INSERT OR IGNORE INTO post_categories (post_id, category_id) VALUES (?, ?); + +-- name: DeletePostCategoriesByPost :exec +DELETE FROM post_categories WHERE post_id = ?; +``` + +- [ ] **Step 2: Run sqlc generate** + +Run: `sqlc generate` +Expected: No errors. New file `providers/db/gen/sqlgen/categories.sql.go` created and `models.go` updated with `Category` and `PostCategory` structs. + +- [ ] **Step 3: Verify the generated code compiles** + +Run: `go build ./providers/db/gen/sqlgen/` +Expected: No errors. + +- [ ] **Step 4: Commit** + +```bash +git add sql/queries/categories.sql providers/db/gen/sqlgen/ +git commit -m "feat: add sqlc queries for categories" +``` + +--- + +## Task 3: DB Provider — Category Methods + +**Files:** +- Create: `providers/db/categories.go` +- Modify: `providers/db/provider.go` + +- [ ] **Step 1: Write failing test for category CRUD** + +Add to `providers/db/provider_test.go`: + +```go +func TestProvider_Categories(t *testing.T) { + ctx := context.Background() + p := newTestDB(t) + + user := &models.User{Username: "testuser", PasswordHashed: []byte("password")} + require.NoError(t, p.SaveUser(ctx, user)) + + site := &models.Site{OwnerID: user.ID, Title: "My Blog", Tagline: "test"} + require.NoError(t, p.SaveSite(ctx, site)) + + t.Run("save and select categories", func(t *testing.T) { + now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC) + cat := &models.Category{ + SiteID: site.ID, + GUID: "cat-001", + Name: "Go Programming", + Slug: "go-programming", + Description: "Posts about Go", + CreatedAt: now, + UpdatedAt: now, + } + + err := p.SaveCategory(ctx, cat) + require.NoError(t, err) + assert.NotZero(t, cat.ID) + + cats, err := p.SelectCategoriesOfSite(ctx, site.ID) + require.NoError(t, err) + require.Len(t, cats, 1) + assert.Equal(t, "Go Programming", cats[0].Name) + assert.Equal(t, "go-programming", cats[0].Slug) + assert.Equal(t, "Posts about Go", cats[0].Description) + }) + + t.Run("update category", func(t *testing.T) { + now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC) + cat := &models.Category{ + SiteID: site.ID, + GUID: "cat-002", + Name: "Original", + Slug: "original", + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, p.SaveCategory(ctx, cat)) + + cat.Name = "Updated" + cat.Slug = "updated" + cat.UpdatedAt = now.Add(time.Hour) + require.NoError(t, p.SaveCategory(ctx, cat)) + + got, err := p.SelectCategory(ctx, cat.ID) + require.NoError(t, err) + assert.Equal(t, "Updated", got.Name) + assert.Equal(t, "updated", got.Slug) + }) + + t.Run("delete category", func(t *testing.T) { + now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC) + cat := &models.Category{ + SiteID: site.ID, + GUID: "cat-003", + Name: "ToDelete", + Slug: "to-delete", + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, p.SaveCategory(ctx, cat)) + + err := p.DeleteCategory(ctx, cat.ID) + require.NoError(t, err) + + _, err = p.SelectCategory(ctx, cat.ID) + assert.Error(t, err) + }) +} + +func TestProvider_PostCategories(t *testing.T) { + ctx := context.Background() + p := newTestDB(t) + + user := &models.User{Username: "testuser", PasswordHashed: []byte("password")} + require.NoError(t, p.SaveUser(ctx, user)) + + site := &models.Site{OwnerID: user.ID, Title: "My Blog", Tagline: "test"} + require.NoError(t, p.SaveSite(ctx, site)) + + now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC) + post := &models.Post{ + SiteID: site.ID, + GUID: "post-pc-001", + Title: "Test Post", + Body: "body", + Slug: "/test", + CreatedAt: now, + } + require.NoError(t, p.SavePost(ctx, post)) + + cat1 := &models.Category{SiteID: site.ID, GUID: "cat-pc-1", Name: "Alpha", Slug: "alpha", CreatedAt: now, UpdatedAt: now} + cat2 := &models.Category{SiteID: site.ID, GUID: "cat-pc-2", Name: "Beta", Slug: "beta", CreatedAt: now, UpdatedAt: now} + require.NoError(t, p.SaveCategory(ctx, cat1)) + require.NoError(t, p.SaveCategory(ctx, cat2)) + + t.Run("set and get post categories", func(t *testing.T) { + err := p.SetPostCategories(ctx, post.ID, []int64{cat1.ID, cat2.ID}) + require.NoError(t, err) + + cats, err := p.SelectCategoriesOfPost(ctx, post.ID) + require.NoError(t, err) + require.Len(t, cats, 2) + assert.Equal(t, "Alpha", cats[0].Name) + assert.Equal(t, "Beta", cats[1].Name) + }) + + t.Run("replace post categories", func(t *testing.T) { + err := p.SetPostCategories(ctx, post.ID, []int64{cat2.ID}) + require.NoError(t, err) + + cats, err := p.SelectCategoriesOfPost(ctx, post.ID) + require.NoError(t, err) + require.Len(t, cats, 1) + assert.Equal(t, "Beta", cats[0].Name) + }) + + t.Run("clear post categories", func(t *testing.T) { + err := p.SetPostCategories(ctx, post.ID, []int64{}) + require.NoError(t, err) + + cats, err := p.SelectCategoriesOfPost(ctx, post.ID) + require.NoError(t, err) + assert.Empty(t, cats) + }) + + t.Run("count posts of category", func(t *testing.T) { + // Publish the post (state=0) + post.State = models.StatePublished + post.PublishedAt = now + require.NoError(t, p.SavePost(ctx, post)) + require.NoError(t, p.SetPostCategories(ctx, post.ID, []int64{cat1.ID})) + + count, err := p.CountPostsOfCategory(ctx, cat1.ID) + require.NoError(t, err) + assert.Equal(t, int64(1), count) + + count, err = p.CountPostsOfCategory(ctx, cat2.ID) + require.NoError(t, err) + assert.Equal(t, int64(0), count) + }) + + t.Run("cascade delete category removes associations", func(t *testing.T) { + require.NoError(t, p.SetPostCategories(ctx, post.ID, []int64{cat1.ID, cat2.ID})) + require.NoError(t, p.DeleteCategory(ctx, cat1.ID)) + + cats, err := p.SelectCategoriesOfPost(ctx, post.ID) + require.NoError(t, err) + require.Len(t, cats, 1) + assert.Equal(t, "Beta", cats[0].Name) + }) +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./providers/db/ -run "TestProvider_Categories|TestProvider_PostCategories" -v` +Expected: FAIL — `SaveCategory`, `SelectCategoriesOfSite`, etc. not defined. + +- [ ] **Step 3: Create the DB provider category methods** + +Create `providers/db/categories.go`: + +```go +package db + +import ( + "context" + "time" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db/gen/sqlgen" +) + +func (db *Provider) SelectCategoriesOfSite(ctx context.Context, siteID int64) ([]*models.Category, error) { + rows, err := db.queries.SelectCategoriesOfSite(ctx, siteID) + if err != nil { + return nil, err + } + cats := make([]*models.Category, len(rows)) + for i, row := range rows { + cats[i] = dbCategoryToCategory(row) + } + return cats, nil +} + +func (db *Provider) SelectCategory(ctx context.Context, id int64) (*models.Category, error) { + row, err := db.queries.SelectCategory(ctx, id) + if err != nil { + return nil, err + } + return dbCategoryToCategory(row), nil +} + +func (db *Provider) SelectCategoryBySlugAndSite(ctx context.Context, siteID int64, slug string) (*models.Category, error) { + row, err := db.queries.SelectCategoryBySlugAndSite(ctx, sqlgen.SelectCategoryBySlugAndSiteParams{ + SiteID: siteID, + Slug: slug, + }) + if err != nil { + return nil, err + } + return dbCategoryToCategory(row), nil +} + +func (db *Provider) SaveCategory(ctx context.Context, cat *models.Category) error { + if cat.ID == 0 { + newID, err := db.queries.InsertCategory(ctx, sqlgen.InsertCategoryParams{ + SiteID: cat.SiteID, + Guid: cat.GUID, + Name: cat.Name, + Slug: cat.Slug, + Description: cat.Description, + CreatedAt: timeToInt(cat.CreatedAt), + UpdatedAt: timeToInt(cat.UpdatedAt), + }) + if err != nil { + return err + } + cat.ID = newID + return nil + } + + return db.queries.UpdateCategory(ctx, sqlgen.UpdateCategoryParams{ + ID: cat.ID, + Name: cat.Name, + Slug: cat.Slug, + Description: cat.Description, + UpdatedAt: timeToInt(cat.UpdatedAt), + }) +} + +func (db *Provider) DeleteCategory(ctx context.Context, id int64) error { + return db.queries.DeleteCategory(ctx, id) +} + +func (db *Provider) SelectCategoriesOfPost(ctx context.Context, postID int64) ([]*models.Category, error) { + rows, err := db.queries.SelectCategoriesOfPost(ctx, postID) + if err != nil { + return nil, err + } + cats := make([]*models.Category, len(rows)) + for i, row := range rows { + cats[i] = dbCategoryToCategory(row) + } + return cats, nil +} + +func (db *Provider) SelectPostsOfCategory(ctx context.Context, categoryID int64, pp PagingParams) ([]*models.Post, error) { + rows, err := db.queries.SelectPostsOfCategory(ctx, sqlgen.SelectPostsOfCategoryParams{ + CategoryID: categoryID, + Limit: pp.Limit, + Offset: pp.Offset, + }) + if err != nil { + return nil, err + } + posts := make([]*models.Post, len(rows)) + for i, row := range rows { + posts[i] = dbPostToPost(row) + } + return posts, nil +} + +func (db *Provider) CountPostsOfCategory(ctx context.Context, categoryID int64) (int64, error) { + return db.queries.CountPostsOfCategory(ctx, categoryID) +} + +// SetPostCategories replaces all category associations for a post. +// It deletes existing associations and inserts the new ones. +func (db *Provider) SetPostCategories(ctx context.Context, postID int64, categoryIDs []int64) error { + if err := db.queries.DeletePostCategoriesByPost(ctx, postID); err != nil { + return err + } + for _, catID := range categoryIDs { + if err := db.queries.InsertPostCategory(ctx, sqlgen.InsertPostCategoryParams{ + PostID: postID, + CategoryID: catID, + }); err != nil { + return err + } + } + return nil +} + +func dbCategoryToCategory(row sqlgen.Category) *models.Category { + return &models.Category{ + ID: row.ID, + SiteID: row.SiteID, + GUID: row.Guid, + Name: row.Name, + Slug: row.Slug, + Description: row.Description, + CreatedAt: time.Unix(row.CreatedAt, 0).UTC(), + UpdatedAt: time.Unix(row.UpdatedAt, 0).UTC(), + } +} +``` + +- [ ] **Step 4: Add BeginTx to provider for future transaction support** + +Add to `providers/db/provider.go`: + +```go +import "database/sql" + +func (db *Provider) BeginTx(ctx context.Context) (*sql.Tx, error) { + return db.drvr.BeginTx(ctx, nil) +} + +func (db *Provider) QueriesWithTx(tx *sql.Tx) *Provider { + return &Provider{ + drvr: db.drvr, + queries: db.queries.WithTx(tx), + } +} +``` + +- [ ] **Step 5: Run the tests** + +Run: `go test ./providers/db/ -run "TestProvider_Categories|TestProvider_PostCategories" -v` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add providers/db/categories.go providers/db/provider.go providers/db/provider_test.go +git commit -m "feat: add DB provider methods for categories" +``` + +--- + +## Task 4: Categories Service + +**Files:** +- Create: `services/categories/service.go` + +- [ ] **Step 1: Create the categories service** + +Create `services/categories/service.go`: + +```go +package categories + +import ( + "context" + "time" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db" + "lmika.dev/lmika/weiro/services/publisher" +) + +type CreateCategoryParams struct { + GUID string `form:"guid" json:"guid"` + Name string `form:"name" json:"name"` + Slug string `form:"slug" json:"slug"` + Description string `form:"description" json:"description"` +} + +type Service struct { + db *db.Provider + publisher *publisher.Queue +} + +func New(db *db.Provider, publisher *publisher.Queue) *Service { + return &Service{db: db, publisher: publisher} +} + +func (s *Service) ListCategories(ctx context.Context) ([]*models.Category, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + return s.db.SelectCategoriesOfSite(ctx, site.ID) +} + +// ListCategoriesWithCounts returns all categories for the site with published post counts. +func (s *Service) ListCategoriesWithCounts(ctx context.Context) ([]models.CategoryWithCount, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + cats, err := s.db.SelectCategoriesOfSite(ctx, site.ID) + if err != nil { + return nil, err + } + + result := make([]models.CategoryWithCount, len(cats)) + for i, cat := range cats { + count, err := s.db.CountPostsOfCategory(ctx, cat.ID) + if err != nil { + return nil, err + } + result[i] = models.CategoryWithCount{ + Category: *cat, + PostCount: int(count), + DescriptionBrief: briefDescription(cat.Description), + } + } + return result, nil +} + +func (s *Service) GetCategory(ctx context.Context, id int64) (*models.Category, error) { + return s.db.SelectCategory(ctx, id) +} + +func (s *Service) CreateCategory(ctx context.Context, params CreateCategoryParams) (*models.Category, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + now := time.Now() + slug := params.Slug + if slug == "" { + slug = models.GenerateCategorySlug(params.Name) + } + + // Check for slug collision + if _, err := s.db.SelectCategoryBySlugAndSite(ctx, site.ID, slug); err == nil { + return nil, models.SlugConflictError + } + + cat := &models.Category{ + SiteID: site.ID, + GUID: params.GUID, + Name: params.Name, + Slug: slug, + Description: params.Description, + CreatedAt: now, + UpdatedAt: now, + } + if cat.GUID == "" { + cat.GUID = models.NewNanoID() + } + + if err := s.db.SaveCategory(ctx, cat); err != nil { + return nil, err + } + + s.publisher.Queue(site) + return cat, nil +} + +func (s *Service) UpdateCategory(ctx context.Context, id int64, params CreateCategoryParams) (*models.Category, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + cat, err := s.db.SelectCategory(ctx, id) + if err != nil { + return nil, err + } + if cat.SiteID != site.ID { + return nil, models.NotFoundError + } + + slug := params.Slug + if slug == "" { + slug = models.GenerateCategorySlug(params.Name) + } + + // Check slug collision (exclude self) + if existing, err := s.db.SelectCategoryBySlugAndSite(ctx, site.ID, slug); err == nil && existing.ID != cat.ID { + return nil, models.SlugConflictError + } + + cat.Name = params.Name + cat.Slug = slug + cat.Description = params.Description + cat.UpdatedAt = time.Now() + + if err := s.db.SaveCategory(ctx, cat); err != nil { + return nil, err + } + + s.publisher.Queue(site) + return cat, nil +} + +func (s *Service) DeleteCategory(ctx context.Context, id int64) error { + site, ok := models.GetSite(ctx) + if !ok { + return models.SiteRequiredError + } + + cat, err := s.db.SelectCategory(ctx, id) + if err != nil { + return err + } + if cat.SiteID != site.ID { + return models.NotFoundError + } + + if err := s.db.DeleteCategory(ctx, id); err != nil { + return err + } + + s.publisher.Queue(site) + return nil +} + +// briefDescription returns the first sentence or line of the description. +func briefDescription(desc string) string { + if desc == "" { + return "" + } + // Find first period followed by space, or first newline + for i, c := range desc { + if c == '\n' { + return desc[:i] + } + if c == '.' && i+1 < len(desc) { + return desc[:i+1] + } + } + return desc +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `go build ./services/categories/` +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add services/categories/service.go +git commit -m "feat: add categories service with CRUD and slug validation" +``` + +--- + +## Task 5: Wire Up Service + Categories Handler + Admin Routes + +**Files:** +- Create: `handlers/categories.go` +- Create: `views/categories/index.html` +- Create: `views/categories/edit.html` +- Modify: `services/services.go` +- Modify: `cmds/server.go` +- Modify: `views/_common/nav.html` + +- [ ] **Step 1: Wire up categories service in services.go** + +Modify `services/services.go` — add to the `Services` struct: + +```go +Categories *categories.Service +``` + +Add to the `New` function (after `uploadService`): + +```go +categoriesService := categories.New(dbp, publisherQueue) +``` + +Add to the return struct: + +```go +Categories: categoriesService, +``` + +Add the import: + +```go +"lmika.dev/lmika/weiro/services/categories" +``` + +- [ ] **Step 2: Create the categories handler** + +Create `handlers/categories.go`: + +```go +package handlers + +import ( + "fmt" + "strconv" + + "github.com/gofiber/fiber/v3" + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/services/categories" +) + +type CategoriesHandler struct { + CategoryService *categories.Service +} + +func (ch CategoriesHandler) Index(c fiber.Ctx) error { + cats, err := ch.CategoryService.ListCategoriesWithCounts(c.Context()) + if err != nil { + return err + } + + return c.Render("categories/index", fiber.Map{ + "categories": cats, + }) +} + +func (ch CategoriesHandler) New(c fiber.Ctx) error { + cat := models.Category{ + GUID: models.NewNanoID(), + } + return c.Render("categories/edit", fiber.Map{ + "category": cat, + "isNew": true, + }) +} + +func (ch CategoriesHandler) Edit(c fiber.Ctx) error { + catID, err := strconv.ParseInt(c.Params("categoryID"), 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + cat, err := ch.CategoryService.GetCategory(c.Context(), catID) + if err != nil { + return err + } + + return c.Render("categories/edit", fiber.Map{ + "category": cat, + "isNew": false, + }) +} + +func (ch CategoriesHandler) Create(c fiber.Ctx) error { + var req categories.CreateCategoryParams + if err := c.Bind().Body(&req); err != nil { + return err + } + + _, err := ch.CategoryService.CreateCategory(c.Context(), req) + if err != nil { + return err + } + + site := models.MustGetSite(c.Context()) + return c.Redirect().To(fmt.Sprintf("/sites/%v/categories", site.ID)) +} + +func (ch CategoriesHandler) Update(c fiber.Ctx) error { + catID, err := strconv.ParseInt(c.Params("categoryID"), 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + var req categories.CreateCategoryParams + if err := c.Bind().Body(&req); err != nil { + return err + } + + _, err = ch.CategoryService.UpdateCategory(c.Context(), catID, req) + if err != nil { + return err + } + + site := models.MustGetSite(c.Context()) + return c.Redirect().To(fmt.Sprintf("/sites/%v/categories", site.ID)) +} + +func (ch CategoriesHandler) Delete(c fiber.Ctx) error { + catID, err := strconv.ParseInt(c.Params("categoryID"), 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + if err := ch.CategoryService.DeleteCategory(c.Context(), catID); err != nil { + return err + } + + site := models.MustGetSite(c.Context()) + return c.Redirect().To(fmt.Sprintf("/sites/%v/categories", site.ID)) +} +``` + +- [ ] **Step 3: Create the category admin templates** + +Create `views/categories/index.html`: + +```html +
+
+

Categories

+
+ New Category +
+
+ + + + + + + + + + + + {{ range .categories }} + + + + + + + {{ else }} + + + + {{ end }} + +
NameSlugPosts
{{ .Name }}{{ .Slug }}{{ .PostCount }} + Edit +
No categories yet.
+
+``` + +Create `views/categories/edit.html`: + +```html +
+
+

{{ if .isNew }}New Category{{ else }}Edit Category{{ end }}

+
+ + {{ if .isNew }} +
+ {{ else }} + + {{ end }} + +
+ +
+ +
+
+
+ +
+ +
Auto-generated from name if left blank.
+
+
+
+ +
+ +
Markdown supported. Displayed on the category archive page.
+
+
+
+
+
+ + {{ if not .isNew }} + + {{ end }} +
+
+
+ + {{ if not .isNew }} + + {{ end }} +
+``` + +- [ ] **Step 4: Register routes in server.go** + +Add to `cmds/server.go` after the `ssh` handler initialization: + +```go +ch := handlers.CategoriesHandler{CategoryService: svcs.Categories} +``` + +Add routes in the `siteGroup` block (after the uploads routes): + +```go +siteGroup.Get("/categories", ch.Index) +siteGroup.Get("/categories/new", ch.New) +siteGroup.Get("/categories/:categoryID", ch.Edit) +siteGroup.Post("/categories", ch.Create) +siteGroup.Post("/categories/:categoryID", ch.Update) +siteGroup.Post("/categories/:categoryID/delete", ch.Delete) +``` + +- [ ] **Step 5: Add "Categories" link to admin nav** + +Modify `views/_common/nav.html` — add after the Posts nav item: + +```html + +``` + +- [ ] **Step 6: Verify the app compiles** + +Run: `go build ./...` +Expected: No errors (ignoring existing build issues in sitereader). + +- [ ] **Step 7: Commit** + +```bash +git add handlers/categories.go views/categories/ views/_common/nav.html services/services.go cmds/server.go +git commit -m "feat: add categories admin UI with CRUD" +``` + +--- + +## Task 6: Post Edit Form — Category Sidebar + +**Files:** +- Modify: `views/posts/edit.html` +- Modify: `handlers/posts.go` +- Modify: `services/posts/create.go` +- Modify: `services/posts/list.go` + +- [ ] **Step 1: Pass categories to the post edit handler** + +Modify `handlers/posts.go` — add `CategoryService` field to `PostsHandler`: + +```go +type PostsHandler struct { + PostService *posts.Service + CategoryService *categories.Service +} +``` + +Add the import for `"lmika.dev/lmika/weiro/services/categories"`. + +In the `New` method, fetch categories and pass them along with selected IDs (empty for new post): + +```go +func (ph PostsHandler) New(c fiber.Ctx) error { + p := models.Post{ + GUID: models.NewNanoID(), + State: models.StateDraft, + } + + cats, err := ph.CategoryService.ListCategories(c.Context()) + if err != nil { + return err + } + + return c.Render("posts/edit", fiber.Map{ + "post": p, + "categories": cats, + "selectedCategories": map[int64]bool{}, + }) +} +``` + +In the `Edit` method, fetch categories and the post's current categories: + +```go +func (ph PostsHandler) Edit(c fiber.Ctx) error { + postIDStr := c.Params("postID") + if postIDStr == "" { + return fiber.ErrBadRequest + } + postID, err := strconv.ParseInt(postIDStr, 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + post, err := ph.PostService.GetPost(c.Context(), postID) + if err != nil { + return err + } + + cats, err := ph.CategoryService.ListCategories(c.Context()) + if err != nil { + return err + } + + postCats, err := ph.PostService.GetPostCategories(c.Context(), postID) + if err != nil { + return err + } + + selectedCategories := make(map[int64]bool) + for _, pc := range postCats { + selectedCategories[pc.ID] = true + } + + return accepts(c, json(func() any { + return post + }), html(func(c fiber.Ctx) error { + return c.Render("posts/edit", fiber.Map{ + "post": post, + "categories": cats, + "selectedCategories": selectedCategories, + }) + })) +} +``` + +- [ ] **Step 2: Add CategoryIDs to CreatePostParams and update service** + +Modify `services/posts/create.go` — add to `CreatePostParams`: + +```go +CategoryIDs []int64 `form:"category_ids" json:"category_ids"` +``` + +Add `GetPostCategories` method to `services/posts/list.go`: + +```go +func (s *Service) GetPostCategories(ctx context.Context, postID int64) ([]*models.Category, error) { + return s.db.SelectCategoriesOfPost(ctx, postID) +} +``` + +Wrap the post save and category assignment in a transaction. Replace the `s.db.SavePost(ctx, post)` call and add category handling: + +```go +// Use a transaction for atomicity of post save + category reassignment +tx, err := s.db.BeginTx(ctx) +if err != nil { + return nil, err +} +defer tx.Rollback() + +txDB := s.db.QueriesWithTx(tx) +if err := txDB.SavePost(ctx, post); err != nil { + return nil, err +} +if err := txDB.SetPostCategories(ctx, post.ID, params.CategoryIDs); err != nil { + return nil, err +} +if err := tx.Commit(); err != nil { + return nil, err +} +``` + +This replaces the existing non-transactional `s.db.SavePost(ctx, post)` call. + +- [ ] **Step 3: Wire CategoryService into PostsHandler in server.go** + +Modify the `ph` initialization in `cmds/server.go`: + +```go +ph := handlers.PostsHandler{PostService: svcs.Posts, CategoryService: svcs.Categories} +``` + +- [ ] **Step 4: Update the post edit template with category sidebar** + +Replace the content of `views/posts/edit.html` with: + +```html +{{ $isPublished := ne .post.State 1 }} +
+
+
+
+ +
+ +
+
+ +
+
+ {{ if $isPublished }} + + {{ else }} + + + {{ end }} +
+
+
+
+
Categories
+
+ {{ range .categories }} +
+ + +
+ {{ else }} + No categories yet. + {{ end }} +
+
+
+
+
+
+``` + +- [ ] **Step 5: Show category badges on post list** + +Modify `services/posts/list.go` — update `ListPosts` to return posts with categories. Add a new type: + +```go +type PostWithCategories struct { + *models.Post + Categories []*models.Category +} +``` + +Update `ListPosts` to return `[]*PostWithCategories`: + +```go +func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*PostWithCategories, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, db.PagingParams{ + Offset: 0, + Limit: 25, + }) + if err != nil { + return nil, err + } + + result := make([]*PostWithCategories, len(posts)) + for i, post := range posts { + cats, err := s.db.SelectCategoriesOfPost(ctx, post.ID) + if err != nil { + return nil, err + } + result[i] = &PostWithCategories{Post: post, Categories: cats} + } + return result, nil +} +``` + +Update `views/posts/index.html` — after the Draft badge or date line (inside the `.mb-3.d-flex` div), add category badges. Replace the date/badge div: + +```html +
+ {{ if eq .State 1 }} + {{ $.user.FormatTime .UpdatedAt }} Draft + {{ else }} + {{ $.user.FormatTime .PublishedAt }} + {{ end }} + {{ range .Categories }} + {{ .Name }} + {{ end }} +
+``` + +Update the handler `Index` method in `handlers/posts.go` — the template variable `posts` stays the same but each item now has a `.Categories` field. + +- [ ] **Step 6: Verify the app compiles** + +Run: `go build ./...` +Expected: No errors. + +- [ ] **Step 7: Commit** + +```bash +git add handlers/posts.go services/posts/create.go services/posts/list.go views/posts/edit.html views/posts/index.html cmds/server.go +git commit -m "feat: add category selection to post edit form and badges to post list" +``` + +--- + +## Task 7: Site Builder — Category Pages + Feeds + +**Files:** +- Modify: `models/pubmodel/sites.go` +- Modify: `providers/sitebuilder/tmpls.go` +- Modify: `providers/sitebuilder/builder.go` +- Create: `layouts/simplecss/categories_list.html` +- Create: `layouts/simplecss/categories_single.html` +- Modify: `services/publisher/service.go` +- Modify: `services/publisher/iter.go` + +- [ ] **Step 1: Extend pubmodel.Site** + +Modify `models/pubmodel/sites.go`: + +```go +package pubmodel + +import ( + "context" + "io" + "iter" + + "lmika.dev/lmika/weiro/models" +) + +type Site struct { + models.Site + BaseURL string + Uploads []models.Upload + + 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) +} +``` + +- [ ] **Step 2: Add template data structs and template names** + +Add to `providers/sitebuilder/tmpls.go`: + +```go +const ( + tmplNameCategoryList = "categories_list.html" + tmplNameCategorySingle = "categories_single.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 +} +``` + +Add to the `postSingleData` struct: + +```go +Categories []*models.Category +``` + +Add the import for `"lmika.dev/lmika/weiro/models"` if not already present. + +- [ ] **Step 3: Create the published site category templates** + +Create `layouts/simplecss/categories_list.html`: + +```html +

Categories

+ +``` + +Create `layouts/simplecss/categories_single.html`: + +```html +

{{ .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 }} +``` + +- [ ] **Step 4: Update the post single template to show categories** + +Modify `layouts/simplecss/posts_single.html`: + +```html +{{ if .Post.Title }}

{{ .Post.Title }}

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

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

+{{ end }} +``` + +- [ ] **Step 5: Update the post list template to show categories** + +Modify `layouts/simplecss/posts_list.html`: + +```html +{{ range .Posts }} + {{ if .Post.Title }}

{{ .Post.Title }}

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

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

+ {{ end }} +{{ end }} +``` + +- [ ] **Step 6: Register new templates in builder.go** + +Modify the `ParseFS` call in `sitebuilder.New()`: + +```go +tmpls, err := template.New(""). + Funcs(templateFns(site, opts)). + ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain, tmplNameCategoryList, tmplNameCategorySingle) +``` + +- [ ] **Step 7: Add category rendering methods to builder.go** + +Add the following methods to `providers/sitebuilder/builder.go`: + +```go +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 + }) +} + +// 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 +} +``` + +- [ ] **Step 8: Update BuildSite to render categories and attach categories to posts** + +Modify `BuildSite` in `providers/sitebuilder/builder.go`. Update the post-writing goroutine and the post-list goroutine to use `renderPostWithCategories`. Add new goroutines for category pages: + +```go +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) + }) +} +``` + +Remove the old `writePost` and `renderPostList` methods as they are replaced. + +- [ ] **Step 8b: Add category metadata to main feeds** + +The `feedhub.Item` struct has a `Category string` field. Update `renderFeeds` in `builder.go` to populate it. After the post is rendered, look up its categories and join the names: + +```go +// In renderFeeds, after renderedPost is created, add: +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, ", ") + } +} + +// Then in the feed.Items append, add: +Category: catName, +``` + +This adds category names to each post entry in the main RSS/JSON feeds. + +- [ ] **Step 9: Add postIterByCategory to publisher/iter.go** + +Add to `services/publisher/iter.go`: + +```go +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 + } + } +} +``` + +- [ ] **Step 10: Populate category data in publisher/service.go** + +In `services/publisher/service.go`, inside the `Publish` method, after fetching uploads and before the target loop, fetch categories: + +```go +// 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: briefDescription(cat.Description), + }) +} +``` + +Add the `briefDescription` helper (same as in categories service — or extract to models): + +```go +func briefDescription(desc string) string { + if desc == "" { + return "" + } + for i, c := range desc { + if c == '\n' { + return desc[:i] + } + if c == '.' && i+1 < len(desc) { + return desc[:i+1] + } + } + return desc +} +``` + +Update the `pubSite` construction to include category fields: + +```go +pubSite := pubmodel.Site{ + Site: site, + PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] { + return p.postIter(ctx, site.ID) + }, + 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) + }, +} +``` + +- [ ] **Step 11: Move briefDescription to models package** + +To avoid duplication, move `briefDescription` to `models/categories.go` as an exported function `BriefDescription`, and update both `services/categories/service.go` and `services/publisher/service.go` to call `models.BriefDescription()`. + +In `models/categories.go`, rename/add: + +```go +func BriefDescription(desc string) string { + if desc == "" { + return "" + } + for i, c := range desc { + if c == '\n' { + return desc[:i] + } + if c == '.' && i+1 < len(desc) { + return desc[:i+1] + } + } + return desc +} +``` + +Update `services/categories/service.go` to use `models.BriefDescription()`. +Update `services/publisher/service.go` to use `models.BriefDescription()` and remove local copy. + +- [ ] **Step 12: Fix the existing builder test** + +The existing test in `providers/sitebuilder/builder_test.go` uses `pubmodel.Site.Posts` which no longer exists. Update it to use `PostIter` and add the new category templates to the template map: + +```go +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 }}`)}, + "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", + 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{ + "2026/02/18/test-post/index.html": "

This is a test post

\n", + "2026/02/20/another-post/index.html": "

This is another test post

\n", + "index.html": "Test Post,Another Post,", + } + + outDir := t.TempDir() + + b, err := sitebuilder.New(site, sitebuilder.Options{ + TemplatesFS: tmpls, + }) + assert.NoError(t, err) + + err = b.BuildSite(outDir) + assert.NoError(t, err) + + for file, content := range wantFiles { + filePath := filepath.Join(outDir, file) + fileContent, err := os.ReadFile(filePath) + assert.NoError(t, err) + assert.Equal(t, content, string(fileContent)) + } + }) +} +``` + +Add imports: `"context"`, `"iter"`. + +- [ ] **Step 13: Fix the existing DB test** + +Update calls to `SelectPostsOfSite` in `providers/db/provider_test.go` to include the `PagingParams` argument: + +Replace all occurrences of `p.SelectPostsOfSite(ctx, , )` with `p.SelectPostsOfSite(ctx, , , db.PagingParams{Limit: 100})`. + +- [ ] **Step 14: Verify the app compiles and tests pass** + +Run: `go build ./...` and `go test ./models/ ./providers/db/ ./providers/sitebuilder/ -v` +Expected: No errors, all tests PASS. + +- [ ] **Step 15: Commit** + +```bash +git add models/pubmodel/sites.go models/categories.go providers/sitebuilder/ layouts/simplecss/ services/publisher/ services/categories/service.go providers/db/provider_test.go +git commit -m "feat: add category pages and per-category feeds to site builder" +``` + +--- + +## Task 8: Final Verification + +- [ ] **Step 1: Verify full build** + +Run: `go build ./...` +Expected: No errors (sitereader may have pre-existing issues — that's OK). + +- [ ] **Step 2: Run all tests** + +Run: `go test ./...` +Expected: All tests pass (pre-existing failures in sitereader/handlers are OK). + +- [ ] **Step 3: Manual smoke test checklist** + +If running the app locally, verify: +1. Navigate to `/sites//categories` — empty list shows +2. Create a new category with name, slug, description +3. Edit the category — changes persist +4. Delete the category — removed from list +5. Edit a post — category sidebar appears on the right +6. Select categories on a post, save — categories persist on reload +7. Post list shows category badges +8. Rebuild site — category index, archive pages, and feeds are generated +9. Empty categories do not appear on published site + +- [ ] **Step 4: Final commit if any cleanup needed** + +```bash +git add -A +git commit -m "chore: categories feature cleanup" +```