Categories
| Name | Slug | Posts | |
|---|---|---|---|
| {{ .Name }} | {{ .Slug }} |
{{ .PostCount }} | Edit |
| No categories yet. | |||
# 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,