# 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 }}
Name Slug Posts
{{ .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" ```