weiro/docs/superpowers/plans/2026-03-18-categories.md
Leon Mika 41c8d1e2f5 Add categories implementation plan
9-task plan covering migration, sqlc queries, DB provider,
service layer, admin UI, post form integration, site builder
with category pages and per-category feeds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 21:29:11 +11:00

54 KiB

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:

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:

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:

var SlugConflictError = errors.New("a category with this slug already exists")
  • Step 4: Write a test for GenerateCategorySlug

Create models/categories_test.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
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:

-- 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
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:

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:

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:

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
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:

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
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:

Categories *categories.Service

Add to the New function (after uploadService):

categoriesService := categories.New(dbp, publisherQueue)

Add to the return struct:

Categories: categoriesService,

Add the import:

"lmika.dev/lmika/weiro/services/categories"
  • Step 2: Create the categories handler

Create handlers/categories.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:

<main class="container">
  <div class="my-4 d-flex justify-content-between align-items-baseline">
    <h4>Categories</h4>
    <div>
      <a href="/sites/{{ .site.ID }}/categories/new" class="btn btn-success">New Category</a>
    </div>
  </div>

  <table class="table">
    <thead>
      <tr>
        <th>Name</th>
        <th>Slug</th>
        <th>Posts</th>
        <th></th>
      </tr>
    </thead>
    <tbody>
      {{ range .categories }}
        <tr>
          <td><a href="/sites/{{ $.site.ID }}/categories/{{ .ID }}">{{ .Name }}</a></td>
          <td><code>{{ .Slug }}</code></td>
          <td>{{ .PostCount }}</td>
          <td>
            <a href="/sites/{{ $.site.ID }}/categories/{{ .ID }}" class="btn btn-outline-secondary btn-sm">Edit</a>
          </td>
        </tr>
      {{ else }}
        <tr>
          <td colspan="4" class="text-center text-muted py-4">No categories yet.</td>
        </tr>
      {{ end }}
    </tbody>
  </table>
</main>

Create views/categories/edit.html:

<main class="container">
  <div class="my-4">
    <h4>{{ if .isNew }}New Category{{ else }}Edit Category{{ end }}</h4>
  </div>

  {{ if .isNew }}
    <form method="post" action="/sites/{{ .site.ID }}/categories">
  {{ else }}
    <form method="post" action="/sites/{{ .site.ID }}/categories/{{ .category.ID }}">
  {{ end }}
    <input type="hidden" name="guid" value="{{ .category.GUID }}">
    <div class="row mb-3">
      <label for="catName" class="col-sm-2 col-form-label">Name</label>
      <div class="col-sm-6">
        <input type="text" class="form-control" id="catName" name="name" value="{{ .category.Name }}">
      </div>
    </div>
    <div class="row mb-3">
      <label for="catSlug" class="col-sm-2 col-form-label">Slug</label>
      <div class="col-sm-6">
        <input type="text" class="form-control" id="catSlug" name="slug" value="{{ .category.Slug }}">
        <div class="form-text">Auto-generated from name if left blank.</div>
      </div>
    </div>
    <div class="row mb-3">
      <label for="catDesc" class="col-sm-2 col-form-label">Description</label>
      <div class="col-sm-9">
        <textarea class="form-control" id="catDesc" name="description" rows="5">{{ .category.Description }}</textarea>
        <div class="form-text">Markdown supported. Displayed on the category archive page.</div>
      </div>
    </div>
    <div class="row mb-3">
      <div class="col-sm-2"></div>
      <div class="col-sm-9">
        <button type="submit" class="btn btn-primary">{{ if .isNew }}Create{{ else }}Save{{ end }}</button>
        {{ if not .isNew }}
          <button type="button" class="btn btn-outline-danger ms-2"
                  onclick="if(confirm('Delete this category? Posts will not be deleted.')) { document.getElementById('delete-form').submit(); }">Delete</button>
        {{ end }}
      </div>
    </div>
  </form>

  {{ if not .isNew }}
    <form id="delete-form" method="post" action="/sites/{{ .site.ID }}/categories/{{ .category.ID }}/delete" style="display:none;"></form>
  {{ end }}
</main>
  • Step 4: Register routes in server.go

Add to cmds/server.go after the ssh handler initialization:

ch := handlers.CategoriesHandler{CategoryService: svcs.Categories}

Add routes in the siteGroup block (after the uploads routes):

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:

<li class="nav-item">
  <a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/categories">Categories</a>
</li>
  • Step 6: Verify the app compiles

Run: go build ./... Expected: No errors (ignoring existing build issues in sitereader).

  • Step 7: Commit
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:

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):

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:

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:

CategoryIDs []int64 `form:"category_ids" json:"category_ids"`

Add GetPostCategories method to services/posts/list.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:

// 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:

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:

{{ $isPublished := ne .post.State 1 }}
<main class="flex-grow-1 position-relative">
  <form action="/sites/{{.site.ID}}/posts" method="post" class="container-fluid post-form p-2"
    data-controller="postedit"
        data-action="keydown.meta+s->postedit#save keydown.meta+enter->postedit#publish"
        data-postedit-save-action-value="{{ if $isPublished }}Update{{ else }}Save Draft{{ end }}">
    <div class="row">
      <div class="col-md-9">
        <input type="hidden" name="guid" value="{{ .post.GUID }}">
        <div class="mb-2">
          <input type="text" name="title" class="form-control" placeholder="Title" value="{{ .post.Title }}">
        </div>
        <div>
          <textarea data-postedit-target="bodyTextEdit" name="body" class="form-control" rows="3">{{.post.Body}}</textarea>
        </div>
        <div>
          {{ if $isPublished }}
            <input type="submit" name="action" class="btn btn-primary mt-2" value="Update">
          {{ else }}
            <input type="submit" name="action" class="btn btn-primary mt-2" value="Publish">
            <input type="submit" name="action" class="btn btn-secondary mt-2" value="Save Draft">
          {{ end }}
        </div>
      </div>
      <div class="col-md-3">
        <div class="card">
          <div class="card-header">Categories</div>
          <div class="card-body">
            {{ range .categories }}
              <div class="form-check">
                <input class="form-check-input" type="checkbox" name="category_ids"
                       value="{{ .ID }}" id="cat-{{ .ID }}"
                       {{ if index $.selectedCategories .ID }}checked{{ end }}>
                <label class="form-check-label" for="cat-{{ .ID }}">{{ .Name }}</label>
              </div>
            {{ else }}
              <span class="text-muted">No categories yet.</span>
            {{ end }}
          </div>
        </div>
      </div>
    </div>
  </form>
</main>
  • Step 5: Show category badges on post list

Modify services/posts/list.go — update ListPosts to return posts with categories. Add a new type:

type PostWithCategories struct {
	*models.Post
	Categories []*models.Category
}

Update ListPosts to return []*PostWithCategories:

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:

<div class="mb-3 d-flex align-items-center flex-wrap gap-1">
  {{ if eq .State 1 }}
    <span class="text-muted">{{ $.user.FormatTime .UpdatedAt }}</span> <span class="ms-3 badge text-primary-emphasis bg-primary-subtle border border-primary-subtle">Draft</span>
  {{ else }}
    <span class="text-muted">{{ $.user.FormatTime .PublishedAt }}</span>
  {{ end }}
  {{ range .Categories }}
    <span class="ms-1 badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">{{ .Name }}</span>
  {{ end }}
</div>

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
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:

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:

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:

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:

<h2>Categories</h2>
<ul>
{{ range .Categories }}
  <li>
    <a href="{{ url_abs .Path }}">{{ .Name }}</a> ({{ .PostCount }})
    {{ if .DescriptionBrief }}<br><small>{{ .DescriptionBrief }}</small>{{ end }}
  </li>
{{ end }}
</ul>

Create layouts/simplecss/categories_single.html:

<h2>{{ .Category.Name }}</h2>
{{ if .DescriptionHTML }}
  <div>{{ .DescriptionHTML }}</div>
{{ end }}
{{ range .Posts }}
  {{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
  {{ .HTML }}
  <a href="{{ url_abs .Path }}">{{ format_date .Post.PublishedAt }}</a>
  {{ if .Categories }}
    <p>
    {{ range .Categories }}
      <a href="{{ url_abs (printf "/categories/%s" .Slug) }}">{{ .Name }}</a>
    {{ end }}
    </p>
  {{ end }}
{{ end }}
  • Step 4: Update the post single template to show categories

Modify layouts/simplecss/posts_single.html:

{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
{{ .HTML }}
<a href="{{ url_abs .Path }}">{{ format_date .Post.PublishedAt }}</a>
{{ if .Categories }}
  <p>
  {{ range .Categories }}
    <a href="{{ url_abs (printf "/categories/%s" .Slug) }}">{{ .Name }}</a>
  {{ end }}
  </p>
{{ end }}
  • Step 5: Update the post list template to show categories

Modify layouts/simplecss/posts_list.html:

{{ range .Posts }}
  {{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
  {{ .HTML }}
  <a href="{{ url_abs .Path }}">{{ format_date .Post.PublishedAt }}</a>
  {{ if .Categories }}
    <p>
    {{ range .Categories }}
      <a href="{{ url_abs (printf "/categories/%s" .Slug) }}">{{ .Name }}</a>
    {{ end }}
    </p>
  {{ end }}
{{ end }}
  • Step 6: Register new templates in builder.go

Modify the ParseFS call in sitebuilder.New():

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:

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:

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:

// 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:

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:

// 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):

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:

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:

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:

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}}<a href="{{url_abs .Path}}">{{.Post.Title}}</a>,{{ end }}`)},
			"layout_main.html":      {Data: []byte(`{{ .Body }}`)},
			"categories_list.html":  {Data: []byte(`{{ range .Categories}}<a href="{{url_abs .Path}}">{{.Name}}</a>,{{ end }}`)},
			"categories_single.html": {Data: []byte(`<h2>{{.Category.Name}}</h2>`)},
		}

		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":    "<p>This is a test post</p>\n",
			"2026/02/20/another-post/index.html": "<p>This is <strong>another</strong> test post</p>\n",
			"index.html":                         "<a href=\"https://example.com/2026/02/18/test-post\">Test Post</a>,<a href=\"https://example.com/2026/02/20/another-post\">Another Post</a>,",
		}

		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, <siteID>, <bool>) with p.SelectPostsOfSite(ctx, <siteID>, <bool>, 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
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/<id>/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
git add -A
git commit -m "chore: categories feature cleanup"