weiro/docs/superpowers/specs/2026-03-18-categories-design.md
Leon Mika 847e8e76d0 Add categories feature design spec
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 21:11:18 +11:00

5.9 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,
    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"`
}
  • 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.

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, 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, ordered by published_at desc
  • CountPostsOfCategory — count of published posts per category
  • 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
  • UpdateCategory(ctx, params) (*Category, error)
  • DeleteCategory(ctx, id) error — deletes category and post associations, queues site rebuild

Changes to services/posts

  • UpdatePost — after saving the post, deletes existing post_categories rows and re-inserts for the selected category IDs
  • GetPost / ListPosts — loads each post's categories for admin display

Changes to Publishing Pipeline

  • pubmodel.Site gains new fields:
    • Category list (with post counts and description excerpts for the index page)
    • A function to iterate published posts by 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

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