Categories
+| Name | +Slug | +Posts | ++ |
|---|---|---|---|
| {{ .Name }} | +{{ .Slug }} |
+ {{ .PostCount }} | ++ Edit + | +
| No categories yet. | +|||
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
+
+
+
+
+
+
+
+ {{ range .categories }}
+ Name
+ Slug
+ Posts
+
+
+
+ {{ else }}
+ {{ .Name }}
+
+ {{ .Slug }}{{ .PostCount }}
+
+ Edit
+
+
+
+ {{ end }}
+
+ No categories yet.
+ {{ if .isNew }}New Category{{ else }}Edit Category{{ end }}
+
+ {{ 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 }}+ {{ 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 }}+ {{ 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(`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,