Add categories feature #3
157
docs/superpowers/specs/2026-03-18-categories-design.md
Normal file
157
docs/superpowers/specs/2026-03-18-categories-design.md
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
# 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`)
|
||||||
|
|
||||||
|
```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`)
|
||||||
|
|
||||||
|
```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.).
|
||||||
Loading…
Reference in a new issue