weiro/docs/superpowers/specs/2026-03-18-categories-design.md
Leon Mika 9a02a2f8af Address spec review feedback for categories design
Adds updated_at field, transaction requirement, slug collision
handling, authorization checks, explicit query filters, pubmodel
signatures, and template registration notes.

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

7.3 KiB

Categories Feature Design

Overview

Add flat, many-to-many categories to Weiro. Categories are managed via a dedicated admin page and assigned to posts on the post edit form. On the published static site, categories appear as labels on posts, archive pages per category, a category index page, and per-category RSS/JSON feeds. Categories with no published posts are hidden from the published site.

Data Model

New Tables (migration 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);

New Go Model (models/categories.go)

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"`
}
  • slug is auto-generated from name (e.g. "Go Programming" -> go-programming), editable by the user.
  • description is Markdown, rendered on the category archive page. Defaults to empty string.
  • DB provider must use the existing timeToInt()/time.Unix() helpers for timestamp conversion, consistent with how posts are handled.

Admin UI

Category Management Page

Route: /sites/:siteID/categories

  • Lists all categories for the site showing name, slug, and post count.
  • "New category" button navigates to a create/edit form.
  • Edit form fields: Name, Slug (auto-generated but editable), Description (Markdown textarea).
  • Delete button with confirmation. Deletes the category and its post associations; does not delete the posts.

Handler: CategoriesHandler (new, in handlers/categories.go). Templates: views/categories/index.html, views/categories/edit.html.

Post Edit Form Changes

  • A multi-select checkbox list of all available categories (sorted alphabetically by name), displayed in a right sidebar alongside the main title/body editing area on the left.
  • Selected category IDs sent with the form submission.
  • CreatePostParams gains CategoryIDs []int64.

Post List (Admin)

  • Category names shown as small labels next to each post title.

Static Site Output

Category Index Page (/categories/)

Lists all categories that have at least one published post. For each category:

  • Category name as a clickable link to the archive page
  • Post count
  • First sentence/line of the description as a brief excerpt

Category Archive Pages (/categories/<slug>/)

  • Category name as heading
  • Full Markdown description rendered below the heading
  • List of published posts in the category, ordered by published_at descending

Post Pages

Each post page displays its category names as clickable links to the corresponding category archive pages.

Feeds

Per-category feeds:

  • /categories/<slug>/feed.xml (RSS)
  • /categories/<slug>/feed.json (JSON Feed)

Main site feeds (/feed.xml, /feed.json) gain category metadata on each post entry.

Empty Category Handling

Categories with no published posts are hidden from the published site: no index entry, no archive page, no feed generated. They remain visible and manageable in the admin UI.

SQL Queries

New file: sql/queries/categories.sql

  • SelectCategoriesOfSite — all categories for a site, ordered by name
  • SelectCategory — single category by ID
  • SelectCategoryByGUID — single category by GUID
  • SelectCategoriesOfPost — categories for a given post (via join table)
  • SelectPostsOfCategory — published, non-deleted posts in a category (state = 0 AND deleted_at = 0), ordered by published_at desc
  • CountPostsOfCategory — count of published posts per category (same state = 0 AND deleted_at = 0 filter)
  • InsertCategory / UpdateCategory / DeleteCategory — CRUD
  • InsertPostCategory / DeletePostCategory — manage the join table
  • DeletePostCategoriesByPost — clear all categories for a post (delete-then-reinsert on save)

Service Layer

New services/categories Package

Service struct with methods:

  • ListCategories(ctx) ([]Category, error) — all categories for the current site (from context)
  • GetCategory(ctx, id) (*Category, error)
  • CreateCategory(ctx, params) (*Category, error) — auto-generates slug from name. If the slug collides with an existing one for the same site, return a validation error.
  • UpdateCategory(ctx, params) (*Category, error) — same slug collision check on update.
  • DeleteCategory(ctx, id) error — deletes category and post associations, queues site rebuild

All mutation methods verify site ownership (same pattern as post service authorization checks).

Changes to services/posts

  • UpdatePost — after saving the post, deletes existing post_categories rows and re-inserts for the selected category IDs. The post save and category reassignment must run within a single database transaction to ensure atomicity.
  • GetPost / ListPosts — loads each post's categories for admin display

Changes to Publishing Pipeline

  • pubmodel.Site gains new fields:
    • Categories []CategoryWithCount — category list with post counts and description excerpts for the index page
    • PostIterByCategory func(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]] — iterator for posts in a specific category
  • sitebuilder.Builder.BuildSite gains additional goroutines for:
    • Rendering the category index page
    • Rendering each category archive page
    • Rendering per-category feeds
  • New templates: tmplNameCategoryList, tmplNameCategorySingle (must be added to the ParseFS call in sitebuilder.New())
  • postSingleData gains a Categories []Category field so post templates can render category links

Rebuild Triggers

Saving or deleting a category queues a site rebuild, same as post state changes.

DB Provider

providers/db/ gains wrapper methods for all new sqlc queries, following the same pattern as existing post methods (e.g. SaveCategory, SelectCategoriesOfPost, etc.).

Design Decisions

  • Hard delete for categories — unlike posts which use soft-delete, categories are hard-deleted. They are simpler entities and don't need a trash/restore workflow.
  • No sort_order column — categories are sorted alphabetically by name. Manual ordering can be added later if needed.
  • Existing microblog-crosspost feed — kept as-is. Per-category feeds are a separate, additive feature.