From 847e8e76d063ea4be36175fb7677f70e55b9917c Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:11:18 +1100 Subject: [PATCH 01/14] Add categories feature design spec Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-03-18-categories-design.md | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-18-categories-design.md diff --git a/docs/superpowers/specs/2026-03-18-categories-design.md b/docs/superpowers/specs/2026-03-18-categories-design.md new file mode 100644 index 0000000..eb5a004 --- /dev/null +++ b/docs/superpowers/specs/2026-03-18-categories-design.md @@ -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//`) + +- 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//feed.xml` (RSS) +- `/categories//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.). From 9a02a2f8af6bead75cb7fd04e1528fb11dee4a6f Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:14:24 +1100 Subject: [PATCH 02/14] 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 --- .../specs/2026-03-18-categories-design.md | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-03-18-categories-design.md b/docs/superpowers/specs/2026-03-18-categories-design.md index eb5a004..9c10abb 100644 --- a/docs/superpowers/specs/2026-03-18-categories-design.md +++ b/docs/superpowers/specs/2026-03-18-categories-design.md @@ -17,6 +17,7 @@ CREATE TABLE categories ( 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); @@ -44,11 +45,13 @@ type Category struct { 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 @@ -66,7 +69,7 @@ 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. +- 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`. @@ -114,8 +117,8 @@ New file: `sql/queries/categories.sql` - `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 +- `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) @@ -128,25 +131,28 @@ New file: `sql/queries/categories.sql` - `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)` +- `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 +- `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: - - Category list (with post counts and description excerpts for the index page) - - A function to iterate published posts by category + - `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` +- 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 @@ -155,3 +161,9 @@ 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. From 41c8d1e2f5d6e70b4e454e6b2fb6f210484c03ee Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:29:11 +1100 Subject: [PATCH 03/14] 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 --- .../plans/2026-03-18-categories.md | 2036 +++++++++++++++++ 1 file changed, 2036 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-18-categories.md diff --git a/docs/superpowers/plans/2026-03-18-categories.md b/docs/superpowers/plans/2026-03-18-categories.md new file mode 100644 index 0000000..b4f3932 --- /dev/null +++ b/docs/superpowers/plans/2026-03-18-categories.md @@ -0,0 +1,2036 @@ +# 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`: + +```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`: + +```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`: + +```go +var SlugConflictError = errors.New("a category with this slug already exists") +``` + +- [ ] **Step 4: Write a test for GenerateCategorySlug** + +Create `models/categories_test.go`: + +```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** + +```bash +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`: + +```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** + +```bash +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`: + +```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`: + +```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`: + +```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** + +```bash +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`: + +```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** + +```bash +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: + +```go +Categories *categories.Service +``` + +Add to the `New` function (after `uploadService`): + +```go +categoriesService := categories.New(dbp, publisherQueue) +``` + +Add to the return struct: + +```go +Categories: categoriesService, +``` + +Add the import: + +```go +"lmika.dev/lmika/weiro/services/categories" +``` + +- [ ] **Step 2: Create the categories handler** + +Create `handlers/categories.go`: + +```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`: + +```html +
+
+

Categories

+ +
+ + + + + + + + + + + + {{ range .categories }} + + + + + + + {{ else }} + + + + {{ end }} + +
NameSlugPosts
{{ .Name }}{{ .Slug }}{{ .PostCount }} + Edit +
No categories yet.
+
+``` + +Create `views/categories/edit.html`: + +```html +
+
+

{{ if .isNew }}New Category{{ else }}Edit Category{{ end }}

+
+ + {{ if .isNew }} +
+ {{ else }} + + {{ end }} + +
+ +
+ +
+
+
+ +
+ +
Auto-generated from name if left blank.
+
+
+
+ +
+ +
Markdown supported. Displayed on the category archive page.
+
+
+
+
+
+ + {{ if not .isNew }} + + {{ end }} +
+
+
+ + {{ if not .isNew }} + + {{ end }} +
+``` + +- [ ] **Step 4: Register routes in server.go** + +Add to `cmds/server.go` after the `ssh` handler initialization: + +```go +ch := handlers.CategoriesHandler{CategoryService: svcs.Categories} +``` + +Add routes in the `siteGroup` block (after the uploads routes): + +```go +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: + +```html + +``` + +- [ ] **Step 6: Verify the app compiles** + +Run: `go build ./...` +Expected: No errors (ignoring existing build issues in sitereader). + +- [ ] **Step 7: Commit** + +```bash +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`: + +```go +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): + +```go +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: + +```go +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`: + +```go +CategoryIDs []int64 `form:"category_ids" json:"category_ids"` +``` + +Add `GetPostCategories` method to `services/posts/list.go`: + +```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: + +```go +// 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`: + +```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: + +```html +{{ $isPublished := ne .post.State 1 }} +
+
+
+
+ +
+ +
+
+ +
+
+ {{ if $isPublished }} + + {{ else }} + + + {{ end }} +
+
+
+
+
Categories
+
+ {{ range .categories }} +
+ + +
+ {{ else }} + No categories yet. + {{ end }} +
+
+
+
+
+
+``` + +- [ ] **Step 5: Show category badges on post list** + +Modify `services/posts/list.go` — update `ListPosts` to return posts with categories. Add a new type: + +```go +type PostWithCategories struct { + *models.Post + Categories []*models.Category +} +``` + +Update `ListPosts` to return `[]*PostWithCategories`: + +```go +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: + +```html +
+ {{ if eq .State 1 }} + {{ $.user.FormatTime .UpdatedAt }} Draft + {{ else }} + {{ $.user.FormatTime .PublishedAt }} + {{ end }} + {{ range .Categories }} + {{ .Name }} + {{ end }} +
+``` + +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** + +```bash +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`: + +```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`: + +```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: + +```go +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`: + +```html +

Categories

+
    +{{ range .Categories }} +
  • + {{ .Name }} ({{ .PostCount }}) + {{ if .DescriptionBrief }}
    {{ .DescriptionBrief }}{{ end }} +
  • +{{ end }} +
+``` + +Create `layouts/simplecss/categories_single.html`: + +```html +

{{ .Category.Name }}

+{{ if .DescriptionHTML }} +
{{ .DescriptionHTML }}
+{{ end }} +{{ range .Posts }} + {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} + {{ .HTML }} + {{ format_date .Post.PublishedAt }} + {{ if .Categories }} +

+ {{ range .Categories }} + {{ .Name }} + {{ end }} +

+ {{ end }} +{{ end }} +``` + +- [ ] **Step 4: Update the post single template to show categories** + +Modify `layouts/simplecss/posts_single.html`: + +```html +{{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} +{{ .HTML }} +{{ format_date .Post.PublishedAt }} +{{ if .Categories }} +

+ {{ range .Categories }} + {{ .Name }} + {{ end }} +

+{{ end }} +``` + +- [ ] **Step 5: Update the post list template to show categories** + +Modify `layouts/simplecss/posts_list.html`: + +```html +{{ range .Posts }} + {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} + {{ .HTML }} + {{ format_date .Post.PublishedAt }} + {{ if .Categories }} +

+ {{ range .Categories }} + {{ .Name }} + {{ end }} +

+ {{ end }} +{{ end }} +``` + +- [ ] **Step 6: Register new templates in builder.go** + +Modify the `ParseFS` call in `sitebuilder.New()`: + +```go +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`: + +```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: + +```go +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: + +```go +// 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`: + +```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: + +```go +// 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): + +```go +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: + +```go +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: + +```go +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: + +```go +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}}{{.Post.Title}},{{ end }}`)}, + "layout_main.html": {Data: []byte(`{{ .Body }}`)}, + "categories_list.html": {Data: []byte(`{{ range .Categories}}{{.Name}},{{ end }}`)}, + "categories_single.html": {Data: []byte(`

{{.Category.Name}}

`)}, + } + + 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": "

This is a test post

\n", + "2026/02/20/another-post/index.html": "

This is another test post

\n", + "index.html": "Test Post,Another Post,", + } + + 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, , )` with `p.SelectPostsOfSite(ctx, , , 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** + +```bash +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//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** + +```bash +git add -A +git commit -m "chore: categories feature cleanup" +``` From 641b402d4a5bdd18998f3ab872511a8b5a5b5c5c Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:32:24 +1100 Subject: [PATCH 04/14] feat: add categories migration and model Co-Authored-By: Claude Sonnet 4.6 --- models/categories.go | 61 +++++++++++++++++++++++++++++++++ models/categories_test.go | 28 +++++++++++++++ models/errors.go | 1 + sql/schema/04_categories.up.sql | 23 +++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 models/categories.go create mode 100644 models/categories_test.go create mode 100644 sql/schema/04_categories.up.sql diff --git a/models/categories.go b/models/categories.go new file mode 100644 index 0000000..5655009 --- /dev/null +++ b/models/categories.go @@ -0,0 +1,61 @@ +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, "-") +} + +// BriefDescription returns the first sentence or line of the description. +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 +} diff --git a/models/categories_test.go b/models/categories_test.go new file mode 100644 index 0000000..facf08b --- /dev/null +++ b/models/categories_test.go @@ -0,0 +1,28 @@ +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)) + }) + } +} diff --git a/models/errors.go b/models/errors.go index 997a952..eda780c 100644 --- a/models/errors.go +++ b/models/errors.go @@ -7,3 +7,4 @@ var PermissionError = errors.New("permission denied") var NotFoundError = errors.New("not found") var SiteRequiredError = errors.New("site required") var DeleteDebounceError = errors.New("permanent delete too soon, try again in a few seconds") +var SlugConflictError = errors.New("a category with this slug already exists") diff --git a/sql/schema/04_categories.up.sql b/sql/schema/04_categories.up.sql new file mode 100644 index 0000000..260d06b --- /dev/null +++ b/sql/schema/04_categories.up.sql @@ -0,0 +1,23 @@ +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); From d47095a90233066a96ad95215136f8390268b0aa Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:33:55 +1100 Subject: [PATCH 05/14] feat: add sqlc queries for categories --- providers/db/gen/sqlgen/categories.sql.go | 305 ++++++++++++++++++ providers/db/gen/sqlgen/db.go | 2 +- providers/db/gen/sqlgen/models.go | 18 +- .../db/gen/sqlgen/pending_uploads.sql.go | 2 +- providers/db/gen/sqlgen/posts.sql.go | 2 +- providers/db/gen/sqlgen/pubtargets.sql.go | 2 +- providers/db/gen/sqlgen/sites.sql.go | 2 +- providers/db/gen/sqlgen/uploads.sql.go | 2 +- providers/db/gen/sqlgen/users.sql.go | 2 +- sql/queries/categories.sql | 53 +++ 10 files changed, 382 insertions(+), 8 deletions(-) create mode 100644 providers/db/gen/sqlgen/categories.sql.go create mode 100644 sql/queries/categories.sql diff --git a/providers/db/gen/sqlgen/categories.sql.go b/providers/db/gen/sqlgen/categories.sql.go new file mode 100644 index 0000000..d5bc40d --- /dev/null +++ b/providers/db/gen/sqlgen/categories.sql.go @@ -0,0 +1,305 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: categories.sql + +package sqlgen + +import ( + "context" +) + +const countPostsOfCategory = `-- 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 +` + +func (q *Queries) CountPostsOfCategory(ctx context.Context, categoryID int64) (int64, error) { + row := q.db.QueryRowContext(ctx, countPostsOfCategory, categoryID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const deleteCategory = `-- name: DeleteCategory :exec +DELETE FROM categories WHERE id = ? +` + +func (q *Queries) DeleteCategory(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteCategory, id) + return err +} + +const deletePostCategoriesByPost = `-- name: DeletePostCategoriesByPost :exec +DELETE FROM post_categories WHERE post_id = ? +` + +func (q *Queries) DeletePostCategoriesByPost(ctx context.Context, postID int64) error { + _, err := q.db.ExecContext(ctx, deletePostCategoriesByPost, postID) + return err +} + +const insertCategory = `-- name: InsertCategory :one +INSERT INTO categories ( + site_id, guid, name, slug, description, created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?) +RETURNING id +` + +type InsertCategoryParams struct { + SiteID int64 + Guid string + Name string + Slug string + Description string + CreatedAt int64 + UpdatedAt int64 +} + +func (q *Queries) InsertCategory(ctx context.Context, arg InsertCategoryParams) (int64, error) { + row := q.db.QueryRowContext(ctx, insertCategory, + arg.SiteID, + arg.Guid, + arg.Name, + arg.Slug, + arg.Description, + arg.CreatedAt, + arg.UpdatedAt, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const insertPostCategory = `-- name: InsertPostCategory :exec +INSERT OR IGNORE INTO post_categories (post_id, category_id) VALUES (?, ?) +` + +type InsertPostCategoryParams struct { + PostID int64 + CategoryID int64 +} + +func (q *Queries) InsertPostCategory(ctx context.Context, arg InsertPostCategoryParams) error { + _, err := q.db.ExecContext(ctx, insertPostCategory, arg.PostID, arg.CategoryID) + return err +} + +const selectCategoriesOfPost = `-- name: SelectCategoriesOfPost :many +SELECT c.id, c.site_id, c.guid, c.name, c.slug, c.description, c.created_at, c.updated_at FROM categories c +INNER JOIN post_categories pc ON pc.category_id = c.id +WHERE pc.post_id = ? +ORDER BY c.name ASC +` + +func (q *Queries) SelectCategoriesOfPost(ctx context.Context, postID int64) ([]Category, error) { + rows, err := q.db.QueryContext(ctx, selectCategoriesOfPost, postID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Category + for rows.Next() { + var i Category + if err := rows.Scan( + &i.ID, + &i.SiteID, + &i.Guid, + &i.Name, + &i.Slug, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const selectCategoriesOfSite = `-- name: SelectCategoriesOfSite :many +SELECT id, site_id, guid, name, slug, description, created_at, updated_at FROM categories +WHERE site_id = ? ORDER BY name ASC +` + +func (q *Queries) SelectCategoriesOfSite(ctx context.Context, siteID int64) ([]Category, error) { + rows, err := q.db.QueryContext(ctx, selectCategoriesOfSite, siteID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Category + for rows.Next() { + var i Category + if err := rows.Scan( + &i.ID, + &i.SiteID, + &i.Guid, + &i.Name, + &i.Slug, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const selectCategory = `-- name: SelectCategory :one +SELECT id, site_id, guid, name, slug, description, created_at, updated_at FROM categories WHERE id = ? LIMIT 1 +` + +func (q *Queries) SelectCategory(ctx context.Context, id int64) (Category, error) { + row := q.db.QueryRowContext(ctx, selectCategory, id) + var i Category + err := row.Scan( + &i.ID, + &i.SiteID, + &i.Guid, + &i.Name, + &i.Slug, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const selectCategoryByGUID = `-- name: SelectCategoryByGUID :one +SELECT id, site_id, guid, name, slug, description, created_at, updated_at FROM categories WHERE guid = ? LIMIT 1 +` + +func (q *Queries) SelectCategoryByGUID(ctx context.Context, guid string) (Category, error) { + row := q.db.QueryRowContext(ctx, selectCategoryByGUID, guid) + var i Category + err := row.Scan( + &i.ID, + &i.SiteID, + &i.Guid, + &i.Name, + &i.Slug, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const selectCategoryBySlugAndSite = `-- name: SelectCategoryBySlugAndSite :one +SELECT id, site_id, guid, name, slug, description, created_at, updated_at FROM categories WHERE site_id = ? AND slug = ? LIMIT 1 +` + +type SelectCategoryBySlugAndSiteParams struct { + SiteID int64 + Slug string +} + +func (q *Queries) SelectCategoryBySlugAndSite(ctx context.Context, arg SelectCategoryBySlugAndSiteParams) (Category, error) { + row := q.db.QueryRowContext(ctx, selectCategoryBySlugAndSite, arg.SiteID, arg.Slug) + var i Category + err := row.Scan( + &i.ID, + &i.SiteID, + &i.Guid, + &i.Name, + &i.Slug, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const selectPostsOfCategory = `-- name: SelectPostsOfCategory :many +SELECT p.id, p.site_id, p.state, p.guid, p.title, p.body, p.slug, p.created_at, p.updated_at, p.published_at, p.deleted_at 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 ? +` + +type SelectPostsOfCategoryParams struct { + CategoryID int64 + Limit int64 + Offset int64 +} + +func (q *Queries) SelectPostsOfCategory(ctx context.Context, arg SelectPostsOfCategoryParams) ([]Post, error) { + rows, err := q.db.QueryContext(ctx, selectPostsOfCategory, arg.CategoryID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Post + for rows.Next() { + var i Post + if err := rows.Scan( + &i.ID, + &i.SiteID, + &i.State, + &i.Guid, + &i.Title, + &i.Body, + &i.Slug, + &i.CreatedAt, + &i.UpdatedAt, + &i.PublishedAt, + &i.DeletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateCategory = `-- name: UpdateCategory :exec +UPDATE categories SET + name = ?, + slug = ?, + description = ?, + updated_at = ? +WHERE id = ? +` + +type UpdateCategoryParams struct { + Name string + Slug string + Description string + UpdatedAt int64 + ID int64 +} + +func (q *Queries) UpdateCategory(ctx context.Context, arg UpdateCategoryParams) error { + _, err := q.db.ExecContext(ctx, updateCategory, + arg.Name, + arg.Slug, + arg.Description, + arg.UpdatedAt, + arg.ID, + ) + return err +} diff --git a/providers/db/gen/sqlgen/db.go b/providers/db/gen/sqlgen/db.go index 7d9d9e7..8eab959 100644 --- a/providers/db/gen/sqlgen/db.go +++ b/providers/db/gen/sqlgen/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.28.0 package sqlgen diff --git a/providers/db/gen/sqlgen/models.go b/providers/db/gen/sqlgen/models.go index 4f69bd0..788c292 100644 --- a/providers/db/gen/sqlgen/models.go +++ b/providers/db/gen/sqlgen/models.go @@ -1,9 +1,20 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.28.0 package sqlgen +type Category struct { + ID int64 + SiteID int64 + Guid string + Name string + Slug string + Description string + CreatedAt int64 + UpdatedAt int64 +} + type PendingUpload struct { ID int64 SiteID int64 @@ -29,6 +40,11 @@ type Post struct { DeletedAt int64 } +type PostCategory struct { + PostID int64 + CategoryID int64 +} + type PublishTarget struct { ID int64 SiteID int64 diff --git a/providers/db/gen/sqlgen/pending_uploads.sql.go b/providers/db/gen/sqlgen/pending_uploads.sql.go index a831bbe..63eeb60 100644 --- a/providers/db/gen/sqlgen/pending_uploads.sql.go +++ b/providers/db/gen/sqlgen/pending_uploads.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.28.0 // source: pending_uploads.sql package sqlgen diff --git a/providers/db/gen/sqlgen/posts.sql.go b/providers/db/gen/sqlgen/posts.sql.go index d512941..8bff191 100644 --- a/providers/db/gen/sqlgen/posts.sql.go +++ b/providers/db/gen/sqlgen/posts.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.28.0 // source: posts.sql package sqlgen diff --git a/providers/db/gen/sqlgen/pubtargets.sql.go b/providers/db/gen/sqlgen/pubtargets.sql.go index cd5cfa6..69c09df 100644 --- a/providers/db/gen/sqlgen/pubtargets.sql.go +++ b/providers/db/gen/sqlgen/pubtargets.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.28.0 // source: pubtargets.sql package sqlgen diff --git a/providers/db/gen/sqlgen/sites.sql.go b/providers/db/gen/sqlgen/sites.sql.go index 1a1b965..bd80fb3 100644 --- a/providers/db/gen/sqlgen/sites.sql.go +++ b/providers/db/gen/sqlgen/sites.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.28.0 // source: sites.sql package sqlgen diff --git a/providers/db/gen/sqlgen/uploads.sql.go b/providers/db/gen/sqlgen/uploads.sql.go index 0433ae9..189de2d 100644 --- a/providers/db/gen/sqlgen/uploads.sql.go +++ b/providers/db/gen/sqlgen/uploads.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.28.0 // source: uploads.sql package sqlgen diff --git a/providers/db/gen/sqlgen/users.sql.go b/providers/db/gen/sqlgen/users.sql.go index a70a3bf..6007589 100644 --- a/providers/db/gen/sqlgen/users.sql.go +++ b/providers/db/gen/sqlgen/users.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.28.0 // source: users.sql package sqlgen diff --git a/sql/queries/categories.sql b/sql/queries/categories.sql new file mode 100644 index 0000000..4b48506 --- /dev/null +++ b/sql/queries/categories.sql @@ -0,0 +1,53 @@ +-- 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 = ?; From 15bc6b7f73e144114fbceaba40c8134ba393efd1 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:37:01 +1100 Subject: [PATCH 06/14] feat: add DB provider methods for categories Implements SaveCategory, SelectCategory, SelectCategoriesOfSite, SelectCategoryBySlugAndSite, DeleteCategory, SelectCategoriesOfPost, SelectPostsOfCategory, CountPostsOfCategory, and SetPostCategories on the DB Provider, along with BeginTx/QueriesWithTx for transaction support. Also fixes pre-existing compilation errors in provider_test.go (missing PagingParams args) so new tests can compile and run. Co-Authored-By: Claude Opus 4.6 --- providers/db/categories.go | 132 +++++++++++++++++++++++++++ providers/db/provider.go | 11 +++ providers/db/provider_test.go | 165 +++++++++++++++++++++++++++++++++- 3 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 providers/db/categories.go diff --git a/providers/db/categories.go b/providers/db/categories.go new file mode 100644 index 0000000..72fac94 --- /dev/null +++ b/providers/db/categories.go @@ -0,0 +1,132 @@ +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. +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(), + } +} diff --git a/providers/db/provider.go b/providers/db/provider.go index eda0513..cc35225 100644 --- a/providers/db/provider.go +++ b/providers/db/provider.go @@ -40,6 +40,17 @@ func (db *Provider) Close() error { return db.drvr.Close() } +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), + } +} + func (db *Provider) SoftDeletePost(ctx context.Context, postID int64) error { return db.queries.SoftDeletePost(ctx, sqlgen.SoftDeletePostParams{ DeletedAt: time.Now().Unix(), diff --git a/providers/db/provider_test.go b/providers/db/provider_test.go index 4781d61..caf83d1 100644 --- a/providers/db/provider_test.go +++ b/providers/db/provider_test.go @@ -158,7 +158,7 @@ func TestProvider_Posts(t *testing.T) { require.NoError(t, err) assert.NotZero(t, post.ID) - posts, err := p.SelectPostsOfSite(ctx, site.ID, false) + posts, err := p.SelectPostsOfSite(ctx, site.ID, false, db.PagingParams{}) require.NoError(t, err) require.Len(t, posts, 1) assert.Equal(t, post.ID, posts[0].ID) @@ -205,7 +205,7 @@ func TestProvider_Posts(t *testing.T) { require.NoError(t, p.SavePost(ctx, post1)) require.NoError(t, p.SavePost(ctx, post2)) - posts, err := p.SelectPostsOfSite(ctx, site2.ID, false) + posts, err := p.SelectPostsOfSite(ctx, site2.ID, false, db.PagingParams{}) require.NoError(t, err) require.Len(t, posts, 2) assert.Equal(t, "New Post", posts[0].Title) @@ -220,7 +220,7 @@ func TestProvider_Posts(t *testing.T) { } require.NoError(t, p.SaveSite(ctx, emptySite)) - posts, err := p.SelectPostsOfSite(ctx, emptySite.ID, false) + posts, err := p.SelectPostsOfSite(ctx, emptySite.ID, false, db.PagingParams{}) require.NoError(t, err) assert.Empty(t, posts) }) @@ -283,6 +283,165 @@ func TestProvider_PublishTargets(t *testing.T) { }) } +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) { + 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) + }) +} + // Verify that password encoding roundtrips correctly through base64 func TestProvider_UserPasswordEncoding(t *testing.T) { ctx := context.Background() From 3c80f63a55168ddc152c9a695722c6c5f1f7b361 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:38:41 +1100 Subject: [PATCH 07/14] feat: add categories service with CRUD and slug validation --- services/categories/service.go | 162 +++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 services/categories/service.go diff --git a/services/categories/service.go b/services/categories/service.go new file mode 100644 index 0000000..57b509d --- /dev/null +++ b/services/categories/service.go @@ -0,0 +1,162 @@ +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: models.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 +} From ffa86b12e94175733c39ab19560876f42ba9c381 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:42:17 +1100 Subject: [PATCH 08/14] feat: add categories admin UI with CRUD Wire up categories service, add CategoriesHandler with full CRUD, create index/edit templates, register routes in server.go, and add Categories nav link. Co-Authored-By: Claude Sonnet 4.6 --- cmds/server.go | 8 +++ handlers/categories.go | 101 ++++++++++++++++++++++++++++++++++++ services/services.go | 4 ++ views/_common/nav.html | 3 ++ views/categories/edit.html | 47 +++++++++++++++++ views/categories/index.html | 35 +++++++++++++ 6 files changed, 198 insertions(+) create mode 100644 handlers/categories.go create mode 100644 views/categories/edit.html create mode 100644 views/categories/index.html diff --git a/cmds/server.go b/cmds/server.go index 40c2690..6b2e71b 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -112,6 +112,7 @@ Starting weiro without any arguments will start the server. ph := handlers.PostsHandler{PostService: svcs.Posts} uh := handlers.UploadsHandler{UploadsService: svcs.Uploads} ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites} + ch := handlers.CategoriesHandler{CategoryService: svcs.Categories} app.Get("/login", lh.Login) app.Post("/login", lh.DoLogin) @@ -141,6 +142,13 @@ Starting weiro without any arguments will start the server. siteGroup.Get("/settings", ssh.General) siteGroup.Post("/settings", ssh.UpdateGeneral) + 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) + app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index) app.Get("/first-run", ih.FirstRun) app.Post("/first-run", ih.FirstRunSubmit) diff --git a/handlers/categories.go b/handlers/categories.go new file mode 100644 index 0000000..ec5e9ca --- /dev/null +++ b/handlers/categories.go @@ -0,0 +1,101 @@ +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)) +} diff --git a/services/services.go b/services/services.go index 606e932..beb6727 100644 --- a/services/services.go +++ b/services/services.go @@ -7,6 +7,7 @@ import ( "lmika.dev/lmika/weiro/providers/db" "lmika.dev/lmika/weiro/providers/uploadfiles" "lmika.dev/lmika/weiro/services/auth" + "lmika.dev/lmika/weiro/services/categories" "lmika.dev/lmika/weiro/services/posts" "lmika.dev/lmika/weiro/services/publisher" "lmika.dev/lmika/weiro/services/sites" @@ -21,6 +22,7 @@ type Services struct { Posts *posts.Service Sites *sites.Service Uploads *uploads.Service + Categories *categories.Service } func New(cfg config.Config) (*Services, error) { @@ -37,6 +39,7 @@ func New(cfg config.Config) (*Services, error) { postService := posts.New(dbp, publisherQueue) siteService := sites.New(dbp) uploadService := uploads.New(dbp, ufp, filepath.Join(cfg.ScratchDir, "uploads", "pending")) + categoriesService := categories.New(dbp, publisherQueue) return &Services{ DB: dbp, @@ -46,6 +49,7 @@ func New(cfg config.Config) (*Services, error) { Posts: postService, Sites: siteService, Uploads: uploadService, + Categories: categoriesService, }, nil } diff --git a/views/_common/nav.html b/views/_common/nav.html index 87801d2..e8bce30 100644 --- a/views/_common/nav.html +++ b/views/_common/nav.html @@ -10,6 +10,9 @@ + diff --git a/views/categories/edit.html b/views/categories/edit.html new file mode 100644 index 0000000..c838778 --- /dev/null +++ b/views/categories/edit.html @@ -0,0 +1,47 @@ +
+
+

{{ if .isNew }}New Category{{ else }}Edit Category{{ end }}

+
+ + {{ if .isNew }} +
+ {{ else }} + + {{ end }} + +
+ +
+ +
+
+
+ +
+ +
Auto-generated from name if left blank.
+
+
+
+ +
+ +
Markdown supported. Displayed on the category archive page.
+
+
+
+
+
+ + {{ if not .isNew }} + + {{ end }} +
+
+
+ + {{ if not .isNew }} + + {{ end }} +
diff --git a/views/categories/index.html b/views/categories/index.html new file mode 100644 index 0000000..f768977 --- /dev/null +++ b/views/categories/index.html @@ -0,0 +1,35 @@ +
+
+

Categories

+ +
+ + + + + + + + + + + + {{ range .categories }} + + + + + + + {{ else }} + + + + {{ end }} + +
NameSlugPosts
{{ .Name }}{{ .Slug }}{{ .PostCount }} + Edit +
No categories yet.
+
From 4c2ce7272d3e9a06e4f2ba5ab06490fb0f3fd4c5 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:45:28 +1100 Subject: [PATCH 09/14] feat: add category selection to post edit form and badges to post list Co-Authored-By: Claude Sonnet 4.6 --- cmds/server.go | 2 +- handlers/posts.go | 32 ++++++++++++++++++++++--- services/posts/create.go | 25 ++++++++++++++++---- services/posts/list.go | 21 +++++++++++++++-- views/posts/edit.html | 51 ++++++++++++++++++++++++++++------------ views/posts/index.html | 11 +++++---- 6 files changed, 112 insertions(+), 30 deletions(-) diff --git a/cmds/server.go b/cmds/server.go index 6b2e71b..56517e7 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -109,7 +109,7 @@ Starting weiro without any arguments will start the server. ih := handlers.IndexHandler{SiteService: svcs.Sites} lh := handlers.LoginHandler{Config: cfg, AuthService: svcs.Auth} - ph := handlers.PostsHandler{PostService: svcs.Posts} + ph := handlers.PostsHandler{PostService: svcs.Posts, CategoryService: svcs.Categories} uh := handlers.UploadsHandler{UploadsService: svcs.Uploads} ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites} ch := handlers.CategoriesHandler{CategoryService: svcs.Categories} diff --git a/handlers/posts.go b/handlers/posts.go index 3f282e0..e0234fc 100644 --- a/handlers/posts.go +++ b/handlers/posts.go @@ -6,11 +6,13 @@ import ( "github.com/gofiber/fiber/v3" "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/services/categories" "lmika.dev/lmika/weiro/services/posts" ) type PostsHandler struct { - PostService *posts.Service + PostService *posts.Service + CategoryService *categories.Service } func (ph PostsHandler) Index(c fiber.Ctx) error { @@ -42,8 +44,15 @@ func (ph PostsHandler) New(c fiber.Ctx) error { State: models.StateDraft, } + cats, err := ph.CategoryService.ListCategories(c.Context()) + if err != nil { + return err + } + return c.Render("posts/edit", fiber.Map{ - "post": p, + "post": p, + "categories": cats, + "selectedCategories": map[int64]bool{}, }) } @@ -62,11 +71,28 @@ func (ph PostsHandler) Edit(c fiber.Ctx) error { 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, + "post": post, + "categories": cats, + "selectedCategories": selectedCategories, }) })) } diff --git a/services/posts/create.go b/services/posts/create.go index f73d49c..b1a6466 100644 --- a/services/posts/create.go +++ b/services/posts/create.go @@ -10,10 +10,11 @@ import ( ) type CreatePostParams struct { - GUID string `form:"guid" json:"guid"` - Title string `form:"title" json:"title"` - Body string `form:"body" json:"body"` - Action string `form:"action" json:"action"` + GUID string `form:"guid" json:"guid"` + Title string `form:"title" json:"title"` + Body string `form:"body" json:"body"` + Action string `form:"action" json:"action"` + CategoryIDs []int64 `form:"category_ids" json:"category_ids"` } func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*models.Post, error) { @@ -53,7 +54,21 @@ func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*mod // Leave unchanged } - if err := s.db.SavePost(ctx, post); err != nil { + // 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 } diff --git a/services/posts/list.go b/services/posts/list.go index ae70e1c..15e14d3 100644 --- a/services/posts/list.go +++ b/services/posts/list.go @@ -7,7 +7,12 @@ import ( "lmika.dev/lmika/weiro/providers/db" ) -func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*models.Post, error) { +type PostWithCategories struct { + *models.Post + Categories []*models.Category +} + +func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*PostWithCategories, error) { site, ok := models.GetSite(ctx) if !ok { return nil, models.SiteRequiredError @@ -21,7 +26,15 @@ func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*models.Po return nil, err } - return posts, nil + 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 } func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error) { @@ -32,3 +45,7 @@ func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error) return post, nil } + +func (s *Service) GetPostCategories(ctx context.Context, postID int64) ([]*models.Category, error) { + return s.db.SelectCategoriesOfPost(ctx, postID) +} diff --git a/views/posts/edit.html b/views/posts/edit.html index 475c9a0..07be770 100644 --- a/views/posts/edit.html +++ b/views/posts/edit.html @@ -4,20 +4,41 @@ 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 }}"> - -
- -
-
- -
-
- {{ if $isPublished }} - - {{ else }} - - - {{ end }} +
+
+ +
+ +
+
+ +
+
+ {{ if $isPublished }} + + {{ else }} + + + {{ end }} +
+
+
+
+
Categories
+
+ {{ range .categories }} +
+ + +
+ {{ else }} + No categories yet. + {{ end }} +
+
+
- \ No newline at end of file + diff --git a/views/posts/index.html b/views/posts/index.html index 6470c24..3d2597f 100644 --- a/views/posts/index.html +++ b/views/posts/index.html @@ -26,11 +26,14 @@ {{ if $p.Title }}

{{ $p.Title }}

{{ end }} {{ markdown $p.Body $.site }} -
- {{ if eq .State 1 }} - {{ $.user.FormatTime .UpdatedAt }} Draft +
+ {{ if eq $p.State 1 }} + {{ $.user.FormatTime $p.UpdatedAt }} Draft {{ else }} - {{ $.user.FormatTime .PublishedAt }} + {{ $.user.FormatTime $p.PublishedAt }} + {{ end }} + {{ range $p.Categories }} + {{ .Name }} {{ end }}
From 6c69131b032492ca254ead9b4b1fb8b73fbc1126 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:51:19 +1100 Subject: [PATCH 10/14] feat: add category pages and per-category feeds to site builder Extend the publishing pipeline to generate category index pages, per-category archive pages, per-category RSS/JSON feeds, and display categories on individual post pages and post lists. Co-Authored-By: Claude Opus 4.6 --- layouts/simplecss/categories_list.html | 9 + layouts/simplecss/categories_single.html | 16 ++ layouts/simplecss/posts_list.html | 7 + layouts/simplecss/posts_single.html | 9 +- models/pubmodel/sites.go | 10 +- providers/sitebuilder/builder.go | 209 +++++++++++++++++++---- providers/sitebuilder/builder_test.go | 43 +++-- providers/sitebuilder/tmpls.go | 33 +++- services/publisher/iter.go | 25 ++- services/publisher/service.go | 29 +++- 10 files changed, 329 insertions(+), 61 deletions(-) create mode 100644 layouts/simplecss/categories_list.html create mode 100644 layouts/simplecss/categories_single.html diff --git a/layouts/simplecss/categories_list.html b/layouts/simplecss/categories_list.html new file mode 100644 index 0000000..32331f6 --- /dev/null +++ b/layouts/simplecss/categories_list.html @@ -0,0 +1,9 @@ +

Categories

+
    +{{ range .Categories }} +
  • + {{ .Name }} ({{ .PostCount }}) + {{ if .DescriptionBrief }}
    {{ .DescriptionBrief }}{{ end }} +
  • +{{ end }} +
\ No newline at end of file diff --git a/layouts/simplecss/categories_single.html b/layouts/simplecss/categories_single.html new file mode 100644 index 0000000..e8d59d1 --- /dev/null +++ b/layouts/simplecss/categories_single.html @@ -0,0 +1,16 @@ +

{{ .Category.Name }}

+{{ if .DescriptionHTML }} +
{{ .DescriptionHTML }}
+{{ end }} +{{ range .Posts }} + {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} + {{ .HTML }} + {{ format_date .Post.PublishedAt }} + {{ if .Categories }} +

+ {{ range .Categories }} + {{ .Name }} + {{ end }} +

+ {{ end }} +{{ end }} \ No newline at end of file diff --git a/layouts/simplecss/posts_list.html b/layouts/simplecss/posts_list.html index 944c5a1..e6a77fe 100644 --- a/layouts/simplecss/posts_list.html +++ b/layouts/simplecss/posts_list.html @@ -2,4 +2,11 @@ {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} {{ .HTML }} {{ format_date .Post.PublishedAt }} + {{ if .Categories }} +

+ {{ range .Categories }} + {{ .Name }} + {{ end }} +

+ {{ end }} {{ end }} \ No newline at end of file diff --git a/layouts/simplecss/posts_single.html b/layouts/simplecss/posts_single.html index 5fd9fcb..cda9bb2 100644 --- a/layouts/simplecss/posts_single.html +++ b/layouts/simplecss/posts_single.html @@ -1,3 +1,10 @@ {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} {{ .HTML }} -{{ format_date .Post.PublishedAt }} \ No newline at end of file +{{ format_date .Post.PublishedAt }} +{{ if .Categories }} +

+ {{ range .Categories }} + {{ .Name }} + {{ end }} +

+{{ end }} \ No newline at end of file diff --git a/models/pubmodel/sites.go b/models/pubmodel/sites.go index a745885..a8862c4 100644 --- a/models/pubmodel/sites.go +++ b/models/pubmodel/sites.go @@ -11,11 +11,11 @@ import ( type Site struct { models.Site BaseURL string - //Posts []*models.Post Uploads []models.Upload - OpenUpload func(u models.Upload) (io.ReadCloser, error) - - // PostItr returns a new post iterator - PostIter func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] + 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) } diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 4908d61..5775149 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -31,7 +31,7 @@ type Builder struct { func New(site pubmodel.Site, opts Options) (*Builder, error) { tmpls, err := template.New(""). Funcs(templateFns(site, opts)). - ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain) + ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain, tmplNameCategoryList, tmplNameCategorySingle) if err != nil { return nil, err } @@ -62,7 +62,13 @@ func (b *Builder) BuildSite(outDir string) error { if err != nil { return err } - if err := b.writePost(buildCtx, post); err != nil { + 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 } } @@ -70,10 +76,7 @@ func (b *Builder) BuildSite(outDir string) error { }) eg.Go(func() error { - if err := b.renderPostList(buildCtx, b.site.PostIter(ctx)); err != nil { - return err - } - return nil + return b.renderPostListWithCategories(buildCtx, ctx) }) eg.Go(func() error { @@ -93,43 +96,42 @@ func (b *Builder) BuildSite(outDir string) error { return nil }) - // Copy uploads + // Category pages eg.Go(func() error { - if err := b.writeUploads(buildCtx, b.site.Uploads); err != nil { + if err := b.renderCategoryList(buildCtx); err != nil { return err } - return nil + return b.renderCategoryPages(buildCtx, ctx) }) - if err := eg.Wait(); err != nil { - return err - } - return nil + // Copy uploads + eg.Go(func() error { + return b.writeUploads(buildCtx, b.site.Uploads) + }) + + return eg.Wait() } -func (b *Builder) renderPostList(ctx buildContext, postIter iter.Seq[models.Maybe[*models.Post]]) error { - // TODO: paging - postCopy := make([]*models.Post, 0) - for mp := range postIter { +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 } - postCopy = append(postCopy, post) + rp, err := b.renderPostWithCategories(ctx, post) + if err != nil { + return err + } + posts = append(posts, rp) } pl := postListData{ commonData: commonData{Site: b.site}, - } - for _, post := range postCopy { - rp, err := b.renderPost(post) - if err != nil { - return err - } - pl.Posts = append(pl.Posts, rp) + Posts: posts, } - return b.createAtPath(ctx, "", func(f io.Writer) error { + return b.createAtPath(bctx, "", func(f io.Writer) error { return b.renderTemplate(f, tmplNamePostList, pl) }) } @@ -156,16 +158,29 @@ func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[* return err } + 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, ", ") + } + } + postTitle := post.Title if postTitle != "" { postTitle = opts.titlePrefix + postTitle } feed.Items = append(feed.Items, &feedhub.Item{ - Id: filepath.Join(b.site.BaseURL, post.GUID), - Title: postTitle, - Link: &feedhub.Link{Href: renderedPost.PostURL}, - Content: string(renderedPost.HTML), + Id: filepath.Join(b.site.BaseURL, post.GUID), + Title: postTitle, + Link: &feedhub.Link{Href: renderedPost.PostURL}, + Content: string(renderedPost.HTML), + Category: catName, // TO FIX: Created should be first published Created: post.PublishedAt, Updated: post.UpdatedAt, @@ -243,14 +258,142 @@ func (b *Builder) renderPost(post *models.Post) (postSingleData, error) { }, nil } -func (b *Builder) writePost(ctx buildContext, post *models.Post) error { +// 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 +} + +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, rp.Path, func(f io.Writer) error { - return b.renderTemplate(f, tmplNamePostSingle, rp) + 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 }) } diff --git a/providers/sitebuilder/builder_test.go b/providers/sitebuilder/builder_test.go index 2564f6d..cbe116b 100644 --- a/providers/sitebuilder/builder_test.go +++ b/providers/sitebuilder/builder_test.go @@ -1,6 +1,8 @@ package sitebuilder_test import ( + "context" + "iter" "os" "path/filepath" "testing" @@ -15,24 +17,36 @@ import ( 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}}{{.Post.Title}},{{ end }}`)}, - "layout_main.html": {Data: []byte(`{{ .Body }}`)}, + "posts_single.html": {Data: []byte(`{{ .HTML }}`)}, + "posts_list.html": {Data: []byte(`{{ range .Posts}}{{.Post.Title}},{{ end }}`)}, + "layout_main.html": {Data: []byte(`{{ .Body }}`)}, + "categories_list.html": {Data: []byte(`{{ range .Categories}}{{.Name}},{{ end }}`)}, + "categories_single.html": {Data: []byte(`

{{.Category.Name}}

`)}, + } + + 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", - 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", - }, + 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{ @@ -58,5 +72,4 @@ func TestBuilder_BuildSite(t *testing.T) { assert.Equal(t, content, string(fileContent)) } }) - } diff --git a/providers/sitebuilder/tmpls.go b/providers/sitebuilder/tmpls.go index fa70b6d..2152290 100644 --- a/providers/sitebuilder/tmpls.go +++ b/providers/sitebuilder/tmpls.go @@ -20,6 +20,12 @@ const ( // tmplNameLayoutMain is the template for the main layout (layoutMainData) tmplNameLayoutMain = "layout_main.html" + + // tmplNameCategoryList is the template for the category index page + tmplNameCategoryList = "categories_list.html" + + // tmplNameCategorySingle is the template for a single category page + tmplNameCategorySingle = "categories_single.html" ) type Options struct { @@ -41,10 +47,11 @@ type commonData struct { type postSingleData struct { commonData - Post *models.Post - HTML template.HTML - Path string - PostURL string + Post *models.Post + HTML template.HTML + Path string + PostURL string + Categories []*models.Category } type postListData struct { @@ -56,3 +63,21 @@ type layoutData struct { commonData Body template.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 +} diff --git a/services/publisher/iter.go b/services/publisher/iter.go index 48b5252..ea70616 100644 --- a/services/publisher/iter.go +++ b/services/publisher/iter.go @@ -8,7 +8,7 @@ import ( "lmika.dev/lmika/weiro/providers/db" ) -// PostIter returns a post iterator which returns posts in reverse chronological order. +// postIter returns a post iterator which returns posts in reverse chronological order. func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] { return func(yield func(models.Maybe[*models.Post]) bool) { paging := db.PagingParams{Offset: 0, Limit: 50} @@ -39,3 +39,26 @@ func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Ma } } } + +// postIterByCategory returns a post iterator for posts in a specific category. +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 + } + } +} diff --git a/services/publisher/service.go b/services/publisher/service.go index f0b39f0..2ed9046 100644 --- a/services/publisher/service.go +++ b/services/publisher/service.go @@ -46,6 +46,24 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { return err } + // 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: models.BriefDescription(cat.Description), + }) + } + for _, target := range targets { if !target.Enabled { continue @@ -56,8 +74,15 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] { return p.postIter(ctx, site.ID) }, - BaseURL: target.BaseURL, - Uploads: uploads, + 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) }, From 9efa40879f6e6e972cde3e4ccfd714b6d60293b5 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 22:11:33 +1100 Subject: [PATCH 11/14] fix: improve error handling in categories service - Slug collision checks now properly propagate real DB errors instead of silently ignoring them - GetCategory now verifies site ownership, matching the pattern used by UpdateCategory and DeleteCategory Co-Authored-By: Claude Opus 4.6 --- services/categories/service.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/services/categories/service.go b/services/categories/service.go index 57b509d..c45280e 100644 --- a/services/categories/service.go +++ b/services/categories/service.go @@ -61,7 +61,19 @@ func (s *Service) ListCategoriesWithCounts(ctx context.Context) ([]models.Catego } func (s *Service) GetCategory(ctx context.Context, id int64) (*models.Category, error) { - return s.db.SelectCategory(ctx, id) + 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 + } + return cat, nil } func (s *Service) CreateCategory(ctx context.Context, params CreateCategoryParams) (*models.Category, error) { @@ -79,6 +91,8 @@ func (s *Service) CreateCategory(ctx context.Context, params CreateCategoryParam // Check for slug collision if _, err := s.db.SelectCategoryBySlugAndSite(ctx, site.ID, slug); err == nil { return nil, models.SlugConflictError + } else if !db.ErrorIsNoRows(err) { + return nil, err } cat := &models.Category{ @@ -124,6 +138,8 @@ func (s *Service) UpdateCategory(ctx context.Context, id int64, params CreateCat // 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 + } else if err != nil && !db.ErrorIsNoRows(err) { + return nil, err } cat.Name = params.Name From 740cf8979ac025aa94f0daf7ee8ddf7184e405f2 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 22:19:26 +1100 Subject: [PATCH 12/14] fix: unit tests --- handlers/posts.go | 3 +- models/ids_test.go | 4 +- providers/db/provider_test.go | 19 +++-- providers/sitereader/provider.go | 94 ----------------------- providers/sitereader/provider_test.go | 106 -------------------------- services/import/service.go | 54 ------------- 6 files changed, 16 insertions(+), 264 deletions(-) delete mode 100644 providers/sitereader/provider.go delete mode 100644 providers/sitereader/provider_test.go delete mode 100644 services/import/service.go diff --git a/handlers/posts.go b/handlers/posts.go index e0234fc..a339685 100644 --- a/handlers/posts.go +++ b/handlers/posts.go @@ -145,8 +145,7 @@ func (ph PostsHandler) Patch(c fiber.Ctx) error { return accepts(c, json(func() any { return struct{}{} }), html(func(c fiber.Ctx) error { - - return c.Redirect().To(fmt.Sprintf("/sites/%v/posts")) + return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", models.MustGetSite(c.Context()).ID)) })) } diff --git a/models/ids_test.go b/models/ids_test.go index e57daf0..8d933fc 100644 --- a/models/ids_test.go +++ b/models/ids_test.go @@ -7,8 +7,8 @@ import ( func TestNewNanoID(t *testing.T) { id := NewNanoID() - if len(id) != 12 { - t.Errorf("Expected ID length of 12, got %d", len(id)) + if len(id) != 16 { + t.Errorf("Expected ID length of 16, got %d", len(id)) } if id == "" { diff --git a/providers/db/provider_test.go b/providers/db/provider_test.go index caf83d1..06f03c0 100644 --- a/providers/db/provider_test.go +++ b/providers/db/provider_test.go @@ -98,6 +98,7 @@ func TestProvider_Sites(t *testing.T) { t.Run("select site by id", func(t *testing.T) { site := &models.Site{ OwnerID: user.ID, + GUID: models.NewNanoID(), Title: "Lookup Blog", Tagline: "Find me by ID", } @@ -143,10 +144,11 @@ func TestProvider_Posts(t *testing.T) { require.NoError(t, p.SaveSite(ctx, site)) t.Run("save and select posts", func(t *testing.T) { + guid := models.NewNanoID() now := time.Date(2026, 2, 19, 12, 0, 0, 0, time.UTC) post := &models.Post{ SiteID: site.ID, - GUID: "post-001", + GUID: guid, Title: "First Post", Body: "Hello world", Slug: "/2026/02/19/first-post", @@ -158,12 +160,12 @@ func TestProvider_Posts(t *testing.T) { require.NoError(t, err) assert.NotZero(t, post.ID) - posts, err := p.SelectPostsOfSite(ctx, site.ID, false, db.PagingParams{}) + posts, err := p.SelectPostsOfSite(ctx, site.ID, false, db.PagingParams{Limit: 10, Offset: 0}) require.NoError(t, err) require.Len(t, posts, 1) assert.Equal(t, post.ID, posts[0].ID) assert.Equal(t, site.ID, posts[0].SiteID) - assert.Equal(t, "post-001", posts[0].GUID) + assert.Equal(t, guid, posts[0].GUID) assert.Equal(t, "First Post", posts[0].Title) assert.Equal(t, "Hello world", posts[0].Body) assert.Equal(t, "/2026/02/19/first-post", posts[0].Slug) @@ -173,8 +175,10 @@ func TestProvider_Posts(t *testing.T) { t.Run("posts ordered by created_at desc", func(t *testing.T) { // Create a second site to isolate this test + guid := models.NewNanoID() site2 := &models.Site{ OwnerID: user.ID, + GUID: models.NewNanoID(), Title: "Second Blog", Tagline: "", } @@ -185,7 +189,7 @@ func TestProvider_Posts(t *testing.T) { post1 := &models.Post{ SiteID: site2.ID, - GUID: "old-post", + GUID: guid, Title: "Old Post", Body: "old", Slug: "/old", @@ -194,7 +198,7 @@ func TestProvider_Posts(t *testing.T) { } post2 := &models.Post{ SiteID: site2.ID, - GUID: "new-post", + GUID: models.NewNanoID(), Title: "New Post", Body: "new", Slug: "/new", @@ -205,7 +209,7 @@ func TestProvider_Posts(t *testing.T) { require.NoError(t, p.SavePost(ctx, post1)) require.NoError(t, p.SavePost(ctx, post2)) - posts, err := p.SelectPostsOfSite(ctx, site2.ID, false, db.PagingParams{}) + posts, err := p.SelectPostsOfSite(ctx, site2.ID, false, db.PagingParams{Limit: 10, Offset: 0}) require.NoError(t, err) require.Len(t, posts, 2) assert.Equal(t, "New Post", posts[0].Title) @@ -215,6 +219,7 @@ func TestProvider_Posts(t *testing.T) { t.Run("select posts for site with no posts", func(t *testing.T) { emptySite := &models.Site{ OwnerID: user.ID, + GUID: models.NewNanoID(), Title: "Empty Blog", Tagline: "", } @@ -239,6 +244,7 @@ func TestProvider_PublishTargets(t *testing.T) { site := &models.Site{ OwnerID: user.ID, + GUID: models.NewNanoID(), Title: "My Blog", Tagline: "A test blog", } @@ -272,6 +278,7 @@ func TestProvider_PublishTargets(t *testing.T) { t.Run("select targets for site with no targets", func(t *testing.T) { emptySite := &models.Site{ OwnerID: user.ID, + GUID: models.NewNanoID(), Title: "No Targets", Tagline: "", } diff --git a/providers/sitereader/provider.go b/providers/sitereader/provider.go deleted file mode 100644 index 1365d4b..0000000 --- a/providers/sitereader/provider.go +++ /dev/null @@ -1,94 +0,0 @@ -package sitereader - -import ( - "bytes" - "io" - "io/fs" - "time" - - "gopkg.in/yaml.v3" - "lmika.dev/lmika/weiro/models" -) - -type Provider struct { - fs fs.FS -} - -func New(fs fs.FS) *Provider { - return &Provider{ - fs: fs, - } -} - -func (p *Provider) ReadSite() (ReadSiteModels, error) { - posts, err := p.ListPosts() - if err != nil { - return ReadSiteModels{}, err - } - - meta := siteMeta{} - metaBytes, err := fs.ReadFile(p.fs, "site.yaml") - if err != nil { - return ReadSiteModels{}, err - } - if err := yaml.Unmarshal(metaBytes, &meta); err != nil { - return ReadSiteModels{}, err - } - - site := models.Site{ - Title: meta.Title, - Tagline: meta.Tagline, - } - - return ReadSiteModels{ - Site: site, - Posts: posts, - }, nil -} - -func (p *Provider) ListPosts() (posts []*models.Post, err error) { - err = fs.WalkDir(p.fs, "posts", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } else if d.IsDir() { - return nil - } - - post, err := p.ReadPost(path) - if err != nil { - return err - } - posts = append(posts, post) - return nil - }) - return posts, err -} - -func (p *Provider) ReadPost(path string) (*models.Post, error) { - data, err := fs.ReadFile(p.fs, path) - if err != nil { - return nil, err - } - - // Split front matter and content - parts := bytes.SplitN(data, []byte("---"), 3) - if len(parts) < 3 { - return nil, io.ErrUnexpectedEOF - } - - var meta postMeta - if err := yaml.Unmarshal(parts[1], &meta); err != nil { - return nil, err - } - - post := models.Post{ - Slug: meta.Slug, - Title: meta.Title, - GUID: meta.ID, - PublishedAt: meta.Date, - CreatedAt: time.Now(), - } - - post.Body = string(bytes.TrimPrefix(parts[2], []byte("\n"))) - return &post, nil -} diff --git a/providers/sitereader/provider_test.go b/providers/sitereader/provider_test.go deleted file mode 100644 index 0b012eb..0000000 --- a/providers/sitereader/provider_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package sitereader_test - -import ( - "testing" - "testing/fstest" - "time" - - "github.com/stretchr/testify/assert" - "lmika.dev/lmika/weiro/providers/sitereader" -) - -func TestProvider_ReadPost(t *testing.T) { - t.Run("with meta", func(t *testing.T) { - testFS := fstest.MapFS{ - "site.yaml": {Data: []byte(`base_url: https://example.com`)}, - "posts/test.md": {Data: []byte(`--- -date: 2026-02-18T19:59:00Z -title: Test Post Here -tags: [test, example] ---- -This is just a test post. -`)}, - } - - pr := sitereader.New(testFS) - - post, err := pr.ReadPost("posts/test.md") - assert.NoError(t, err) - assert.Equal(t, "Test Post Here", post.Title) - assert.Equal(t, time.Date(2026, 2, 18, 19, 59, 0, 0, time.UTC), post.PublishedAt) - assert.Equal(t, "This is just a test post.\n", post.Body) - }) - - t.Run("without meta", func(t *testing.T) { - testFS := fstest.MapFS{ - "posts/test.md": {Data: []byte(`--- ---- -This is just a test post. -`)}, - } - - pr := sitereader.New(testFS) - - post, err := pr.ReadPost("posts/test.md") - assert.NoError(t, err) - assert.Equal(t, "", post.Title) - assert.Equal(t, "This is just a test post.\n", post.Body) - }) -} - -func TestProvider_ListPosts(t *testing.T) { - testFS := fstest.MapFS{ - "posts/01-post1.md": {Data: []byte(`--- -id: 111 -date: 2026-02-18T19:59:00Z -title: Test Post Here -tags: [test, example] ---- -This is just a test post. -`)}, - "posts/02-post2.md": {Data: []byte(`--- -id: 222 ---- -This is just a test post. -`)}, - } - - pr := sitereader.New(testFS) - - posts, err := pr.ListPosts() - assert.NoError(t, err) - - assert.Equal(t, 2, len(posts)) - - assert.Equal(t, "111", posts[0].GUID) - assert.Equal(t, "222", posts[1].GUID) -} - -func TestProvider_ReadSite(t *testing.T) { - testFS := fstest.MapFS{ - "site.yaml": {Data: []byte(`base_url: https://example.com`)}, - "posts/01-post1.md": {Data: []byte(`--- -id: 111 -date: 2026-02-18T19:59:00Z -title: Test Post Here -tags: [test, example] ---- -This is just a test post. -`)}, - "posts/02-post2.md": {Data: []byte(`--- -id: 222 ---- -This is just a test post. -`)}, - } - - pr := sitereader.New(testFS) - - sites, err := pr.ReadSite() - assert.NoError(t, err) - - assert.Equal(t, 2, len(sites.Posts)) - - assert.Equal(t, "111", sites.Posts[0].GUID) - assert.Equal(t, "222", sites.Posts[1].GUID) -} diff --git a/services/import/service.go b/services/import/service.go deleted file mode 100644 index e4aee94..0000000 --- a/services/import/service.go +++ /dev/null @@ -1,54 +0,0 @@ -package _import - -import ( - "context" - "os" - - "emperror.dev/errors" - "lmika.dev/lmika/weiro/models" - "lmika.dev/lmika/weiro/providers/db" - "lmika.dev/lmika/weiro/providers/sitereader" -) - -type Service struct { - db *db.Provider -} - -func New(db *db.Provider) *Service { - return &Service{ - db: db, - } -} - -func (s *Service) Import(ctx context.Context, sitePath string) (models.Site, error) { - user, ok := models.GetUser(ctx) - if !ok { - return models.Site{}, models.UserRequiredError - } - - sr := sitereader.New(os.DirFS(sitePath)) - - readSite, err := sr.ReadSite() - if err != nil { - return models.Site{}, errors.Wrap(err, "failed to read site") - } - - site := readSite.Site - site.OwnerID = user.ID - - if err := s.db.SaveSite(ctx, &site); err != nil { - return models.Site{}, errors.Wrap(err, "failed to save site") - } - - for _, post := range readSite.Posts { - post.SiteID = site.ID - if post.GUID == "" { - post.GUID = models.NewNanoID() - } - if err := s.db.SavePost(ctx, post); err != nil { - return models.Site{}, errors.Wrap(err, "failed to save post") - } - } - - return site, nil -} From f45bdcd83c31140392cf89cc76412df4f294ddbf Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 21 Mar 2026 12:01:24 +1100 Subject: [PATCH 13/14] Styled the admin section of categories. --- assets/css/main.scss | 59 ++++++++++++++++++++++++-------- handlers/posts.go | 2 ++ providers/sitebuilder/builder.go | 9 ++--- views/categories/edit.html | 10 +++--- views/categories/index.html | 39 +++++++++------------ views/layouts/main.html | 2 +- views/posts/edit.html | 6 ++-- views/posts/index.html | 7 ++-- 8 files changed, 78 insertions(+), 56 deletions(-) diff --git a/assets/css/main.scss b/assets/css/main.scss index c8f0344..dc6ad7d 100644 --- a/assets/css/main.scss +++ b/assets/css/main.scss @@ -10,21 +10,7 @@ $container-max-widths: ( @import "bootstrap/scss/bootstrap.scss"; -// Local classes - -.post-form { - display: grid; - grid-template-rows: min-content auto min-content; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; -} - -.post-form textarea { - height: 100%; -} +// Post list .postlist .post img { max-width: 300px; @@ -32,6 +18,49 @@ $container-max-widths: ( max-height: 300px; } +.postlist .post-date { + font-size: 0.9rem; +} + +// Post form + +// Post edit page styling +.post-edit-page { + height: 100vh; +} + +.post-edit-page main { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.post-edit-page .post-form { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.post-edit-page .post-form .row { + flex: 1; + display: flex; + min-height: 0; +} + +.post-edit-page .post-form .col-md-9 { + display: flex; + flex-direction: column; +} + +.post-edit-page .post-form textarea { + flex: 1; + resize: vertical; + min-height: 300px; +} + + + .show-upload figure img { max-width: 100vw; height: auto; diff --git a/handlers/posts.go b/handlers/posts.go index a339685..a133758 100644 --- a/handlers/posts.go +++ b/handlers/posts.go @@ -53,6 +53,7 @@ func (ph PostsHandler) New(c fiber.Ctx) error { "post": p, "categories": cats, "selectedCategories": map[int64]bool{}, + "bodyClass": "post-edit-page", }) } @@ -93,6 +94,7 @@ func (ph PostsHandler) Edit(c fiber.Ctx) error { "post": post, "categories": cats, "selectedCategories": selectedCategories, + "bodyClass": "post-edit-page", }) })) } diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 5775149..346f77c 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -176,10 +176,11 @@ func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[* } feed.Items = append(feed.Items, &feedhub.Item{ - Id: filepath.Join(b.site.BaseURL, post.GUID), - Title: postTitle, - Link: &feedhub.Link{Href: renderedPost.PostURL}, - Content: string(renderedPost.HTML), + Id: filepath.Join(b.site.BaseURL, post.GUID), + Title: postTitle, + Link: &feedhub.Link{Href: renderedPost.PostURL}, + Content: string(renderedPost.HTML), + // TO FIX: Why the heck does this only include the first category? Category: catName, // TO FIX: Created should be first published Created: post.PublishedAt, diff --git a/views/categories/edit.html b/views/categories/edit.html index c838778..c6c3606 100644 --- a/views/categories/edit.html +++ b/views/categories/edit.html @@ -1,6 +1,6 @@
-

{{ if .isNew }}New Category{{ else }}Edit Category{{ end }}

+
{{ if .isNew }}New Category{{ else }}Edit Category{{ end }}
{{ if .isNew }} @@ -10,27 +10,27 @@ {{ end }}
- +
- +
Auto-generated from name if left blank.
- +
Markdown supported. Displayed on the category archive page.
-
+
{{ if not .isNew }} diff --git a/views/categories/index.html b/views/categories/index.html index f768977..026d919 100644 --- a/views/categories/index.html +++ b/views/categories/index.html @@ -1,35 +1,30 @@
-

Categories

- - - - - - - - - - - {{ range .categories }} + {{ range .categories }} +
NameSlugPosts
+ + + + + + + + - - {{ else }} - - - - {{ end }} - -
NameSlugPosts
{{ .Name }} {{ .Slug }} {{ .PostCount }} - Edit -
No categories yet.
+ + + {{ else }} +
+
📚
No categories yet.
+
+ {{ end }}
diff --git a/views/layouts/main.html b/views/layouts/main.html index 2b81177..908094f 100644 --- a/views/layouts/main.html +++ b/views/layouts/main.html @@ -7,7 +7,7 @@ - + {{ template "_common/nav" . }} {{ embed }} diff --git a/views/posts/edit.html b/views/posts/edit.html index 07be770..d162788 100644 --- a/views/posts/edit.html +++ b/views/posts/edit.html @@ -1,6 +1,6 @@ {{ $isPublished := ne .post.State 1 }}
-
@@ -10,9 +10,7 @@
-
- -
+
{{ if $isPublished }} diff --git a/views/posts/index.html b/views/posts/index.html index 3d2597f..bbf445d 100644 --- a/views/posts/index.html +++ b/views/posts/index.html @@ -28,12 +28,9 @@
{{ if eq $p.State 1 }} - {{ $.user.FormatTime $p.UpdatedAt }} Draft + Draft {{ else }} - {{ $.user.FormatTime $p.PublishedAt }} - {{ end }} - {{ range $p.Categories }} - {{ .Name }} + {{ end }}
From d9aec4af2c98abf0fc0f96f50b4b5f24f0ed2fb2 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 10:28:33 +1100 Subject: [PATCH 14/14] Styled the categories on the site --- layouts/simplecss/categories_list.html | 9 --- layouts/simplecss/categories_single.html | 16 ------ layouts/simplecss/fs.go | 3 +- layouts/simplecss/posts_list.html | 12 ---- layouts/simplecss/posts_single.html | 10 ---- layouts/simplecss/static/style.css | 55 +++++++++++++++++++ layouts/simplecss/templates/_post_meta.html | 10 ++++ .../simplecss/templates/categories_list.html | 9 +++ .../templates/categories_single.html | 11 ++++ .../{ => templates}/layout_main.html | 1 + layouts/simplecss/templates/posts_list.html | 8 +++ layouts/simplecss/templates/posts_single.html | 5 ++ providers/sitebuilder/builder.go | 47 +++++++++++++++- providers/sitebuilder/tmpls.go | 9 ++- services/publisher/service.go | 16 +++++- views/categories/index.html | 14 +++-- 16 files changed, 176 insertions(+), 59 deletions(-) delete mode 100644 layouts/simplecss/categories_list.html delete mode 100644 layouts/simplecss/categories_single.html delete mode 100644 layouts/simplecss/posts_list.html delete mode 100644 layouts/simplecss/posts_single.html create mode 100644 layouts/simplecss/static/style.css create mode 100644 layouts/simplecss/templates/_post_meta.html create mode 100644 layouts/simplecss/templates/categories_list.html create mode 100644 layouts/simplecss/templates/categories_single.html rename layouts/simplecss/{ => templates}/layout_main.html (89%) create mode 100644 layouts/simplecss/templates/posts_list.html create mode 100644 layouts/simplecss/templates/posts_single.html diff --git a/layouts/simplecss/categories_list.html b/layouts/simplecss/categories_list.html deleted file mode 100644 index 32331f6..0000000 --- a/layouts/simplecss/categories_list.html +++ /dev/null @@ -1,9 +0,0 @@ -

Categories

-
    -{{ range .Categories }} -
  • - {{ .Name }} ({{ .PostCount }}) - {{ if .DescriptionBrief }}
    {{ .DescriptionBrief }}{{ end }} -
  • -{{ end }} -
\ No newline at end of file diff --git a/layouts/simplecss/categories_single.html b/layouts/simplecss/categories_single.html deleted file mode 100644 index e8d59d1..0000000 --- a/layouts/simplecss/categories_single.html +++ /dev/null @@ -1,16 +0,0 @@ -

{{ .Category.Name }}

-{{ if .DescriptionHTML }} -
{{ .DescriptionHTML }}
-{{ end }} -{{ range .Posts }} - {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} - {{ .HTML }} - {{ format_date .Post.PublishedAt }} - {{ if .Categories }} -

- {{ range .Categories }} - {{ .Name }} - {{ end }} -

- {{ end }} -{{ end }} \ No newline at end of file diff --git a/layouts/simplecss/fs.go b/layouts/simplecss/fs.go index 2c1b2fb..d82f6ae 100644 --- a/layouts/simplecss/fs.go +++ b/layouts/simplecss/fs.go @@ -2,5 +2,6 @@ package simplecss import "embed" -//go:embed *.html +//go:embed templates/*.html +//go:embed static/* var FS embed.FS diff --git a/layouts/simplecss/posts_list.html b/layouts/simplecss/posts_list.html deleted file mode 100644 index e6a77fe..0000000 --- a/layouts/simplecss/posts_list.html +++ /dev/null @@ -1,12 +0,0 @@ -{{ range .Posts }} - {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} - {{ .HTML }} - {{ format_date .Post.PublishedAt }} - {{ if .Categories }} -

- {{ range .Categories }} - {{ .Name }} - {{ end }} -

- {{ end }} -{{ end }} \ No newline at end of file diff --git a/layouts/simplecss/posts_single.html b/layouts/simplecss/posts_single.html deleted file mode 100644 index cda9bb2..0000000 --- a/layouts/simplecss/posts_single.html +++ /dev/null @@ -1,10 +0,0 @@ -{{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} -{{ .HTML }} -{{ format_date .Post.PublishedAt }} -{{ if .Categories }} -

- {{ range .Categories }} - {{ .Name }} - {{ end }} -

-{{ end }} \ No newline at end of file diff --git a/layouts/simplecss/static/style.css b/layouts/simplecss/static/style.css new file mode 100644 index 0000000..cdfc4c2 --- /dev/null +++ b/layouts/simplecss/static/style.css @@ -0,0 +1,55 @@ +.h-entry { + margin-block-start: 1.5rem; + margin-block-end: 2.5rem; +} + +.post-meta { + display: flex; + flex-direction: row; + justify-content: space-between; + font-size: 0.95rem; +} + +.post-meta a { + color: var(--text-light); + text-decoration: none; +} + +.post-meta a:hover { + text-decoration: underline; +} + +.post-categories { + display: inline-flex; + gap: 0.5rem; +} + +.post-categories a:before { + content: "#"; +} + +/* Category list */ + +ul.category-list { + list-style: none; + padding-inline-start: 0; +} + +ul.category-list li { + display: flex; + flex-direction: row; + + justify-content: start; + gap: 4rem; +} + +ul.category-list span.category-list-name { + min-width: 15vw; +} + +/* Category single */ + +.category-description { + margin-block-start: 1.5rem; + margin-block-end: 2.5rem; +} \ No newline at end of file diff --git a/layouts/simplecss/templates/_post_meta.html b/layouts/simplecss/templates/_post_meta.html new file mode 100644 index 0000000..a042f41 --- /dev/null +++ b/layouts/simplecss/templates/_post_meta.html @@ -0,0 +1,10 @@ + \ No newline at end of file diff --git a/layouts/simplecss/templates/categories_list.html b/layouts/simplecss/templates/categories_list.html new file mode 100644 index 0000000..e5fc8c8 --- /dev/null +++ b/layouts/simplecss/templates/categories_list.html @@ -0,0 +1,9 @@ +

Categories

+
    +{{ range .Categories }} +
  • + {{ .Name }} ({{ .PostCount }}) + {{ if .DescriptionBrief }}{{ .DescriptionBrief }}{{ end }} +
  • +{{ end }} +
\ No newline at end of file diff --git a/layouts/simplecss/templates/categories_single.html b/layouts/simplecss/templates/categories_single.html new file mode 100644 index 0000000..deaeb02 --- /dev/null +++ b/layouts/simplecss/templates/categories_single.html @@ -0,0 +1,11 @@ +

{{ .Category.Name }}

+{{ if .DescriptionHTML }} +
{{ .DescriptionHTML }}
+{{ end }} +{{ range .Posts }} +
+ {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} + {{ .HTML }} + {{ template "_post_meta.html" . }} +
+{{ end }} \ No newline at end of file diff --git a/layouts/simplecss/layout_main.html b/layouts/simplecss/templates/layout_main.html similarity index 89% rename from layouts/simplecss/layout_main.html rename to layouts/simplecss/templates/layout_main.html index cc2e616..4aa5199 100644 --- a/layouts/simplecss/layout_main.html +++ b/layouts/simplecss/templates/layout_main.html @@ -7,6 +7,7 @@ +
diff --git a/layouts/simplecss/templates/posts_list.html b/layouts/simplecss/templates/posts_list.html new file mode 100644 index 0000000..5f10f1e --- /dev/null +++ b/layouts/simplecss/templates/posts_list.html @@ -0,0 +1,8 @@ +{{ range .Posts }} +
+ {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} + {{ .HTML }} + + {{ template "_post_meta.html" . }} +
+{{ end }} \ No newline at end of file diff --git a/layouts/simplecss/templates/posts_single.html b/layouts/simplecss/templates/posts_single.html new file mode 100644 index 0000000..8895b19 --- /dev/null +++ b/layouts/simplecss/templates/posts_single.html @@ -0,0 +1,5 @@ +
+ {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} + {{ .HTML }} + {{ template "_post_meta.html" . }} +
\ No newline at end of file diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 346f77c..1a4275d 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -6,7 +6,9 @@ import ( "fmt" "html/template" "io" + "io/fs" "iter" + "log" "os" "path/filepath" "strings" @@ -31,11 +33,15 @@ type Builder struct { func New(site pubmodel.Site, opts Options) (*Builder, error) { tmpls, err := template.New(""). Funcs(templateFns(site, opts)). - ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain, tmplNameCategoryList, tmplNameCategorySingle) + ParseFS(opts.TemplatesFS, "*.html") if err != nil { return nil, err } + for _, t := range tmpls.Templates() { + log.Printf("Loaded template %s", t.Name()) + } + return &Builder{ site: site, opts: opts, @@ -109,6 +115,9 @@ func (b *Builder) BuildSite(outDir string) error { return b.writeUploads(buildCtx, b.site.Uploads) }) + // Build static assets + eg.Go(func() error { return b.writeStaticAssets(buildCtx) }) + return eg.Wait() } @@ -432,7 +441,7 @@ func (b *Builder) renderTemplate(w io.Writer, name string, data interface{}) err func (b *Builder) writeUploads(ctx buildContext, uploads []models.Upload) error { for _, u := range uploads { - fullPath := filepath.Join(ctx.outDir, "uploads", u.Slug) + fullPath := filepath.Join(ctx.outDir, b.opts.BaseUploads, u.Slug) if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { return err } @@ -460,3 +469,37 @@ func (b *Builder) writeUploads(ctx buildContext, uploads []models.Upload) error } return nil } + +func (b *Builder) writeStaticAssets(ctx buildContext) error { + return fs.WalkDir(b.opts.StaticFS, ".", func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } else if d.IsDir() { + return nil + } + + fullPath := filepath.Join(ctx.outDir, b.opts.BaseStatic, path) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + return err + } + + return func() error { + r, err := b.opts.StaticFS.Open(path) + if err != nil { + return err + } + defer r.Close() + + w, err := os.Create(fullPath) + if err != nil { + return err + } + defer w.Close() + + if _, err := io.Copy(w, r); err != nil { + return err + } + return nil + }() + }) +} diff --git a/providers/sitebuilder/tmpls.go b/providers/sitebuilder/tmpls.go index 2152290..cea02f5 100644 --- a/providers/sitebuilder/tmpls.go +++ b/providers/sitebuilder/tmpls.go @@ -29,12 +29,17 @@ const ( ) type Options struct { - // BasePosts is the base path for posts. - BasePosts string + BasePosts string // BasePosts is the base path for posts. + BaseUploads string // BaseUploads is the base path for uploads. + BaseStatic string // BaseStatic is the base path for static assets. // TemplatesFS provides the raw templates for rendering the site. TemplatesFS fs.FS + // StaticFS provides the raw assets for the site. This will be written as is + // from the BaseStatic dir. + StaticFS fs.FS + // FeedItems holds the number of posts to show in the feed. FeedItems int diff --git a/services/publisher/service.go b/services/publisher/service.go index 2ed9046..939817a 100644 --- a/services/publisher/service.go +++ b/services/publisher/service.go @@ -3,6 +3,7 @@ package publisher import ( "context" "io" + "io/fs" "iter" "log" "os" @@ -102,9 +103,22 @@ func (p *Publisher) publishSite(ctx context.Context, pubSite pubmodel.Site, targ renderTZ = time.UTC } + templateFS, err := fs.Sub(simplecss.FS, "templates") + if err != nil { + return err + } + + staticFS, err := fs.Sub(simplecss.FS, "static") + if err != nil { + return err + } + sb, err := sitebuilder.New(pubSite, sitebuilder.Options{ BasePosts: "/posts", - TemplatesFS: simplecss.FS, + BaseUploads: "/uploads", + BaseStatic: "/static", + TemplatesFS: templateFS, + StaticFS: staticFS, FeedItems: 30, RenderTZ: renderTZ, }) diff --git a/views/categories/index.html b/views/categories/index.html index 026d919..2d17beb 100644 --- a/views/categories/index.html +++ b/views/categories/index.html @@ -5,7 +5,7 @@
- {{ range .categories }} + {{ if .categories }} @@ -15,11 +15,13 @@ - - - - - + {{ range .categories }} + + + + + + {{ end }}
{{ .Name }}{{ .Slug }}{{ .PostCount }}
{{ .Name }}{{ .Slug }}{{ .PostCount }}
{{ else }}