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>
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"`
}
slugis auto-generated fromname(e.g. "Go Programming" ->go-programming), editable by the user.descriptionis 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.
CreatePostParamsgainsCategoryIDs []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_atdescending
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 nameSelectCategory— single category by IDSelectCategoryByGUID— single category by GUIDSelectCategoriesOfPost— categories for a given post (via join table)SelectPostsOfCategory— published, non-deleted posts in a category (state = 0 AND deleted_at = 0), ordered bypublished_atdescCountPostsOfCategory— count of published posts per category (samestate = 0 AND deleted_at = 0filter)InsertCategory/UpdateCategory/DeleteCategory— CRUDInsertPostCategory/DeletePostCategory— manage the join tableDeletePostCategoriesByPost— 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 existingpost_categoriesrows 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.Sitegains new fields:Categories []CategoryWithCount— category list with post counts and description excerpts for the index pagePostIterByCategory func(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]]— iterator for posts in a specific category
sitebuilder.Builder.BuildSitegains 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 theParseFScall insitebuilder.New()) postSingleDatagains aCategories []Categoryfield 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.