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/cmds/server.go b/cmds/server.go
index 40c2690..56517e7 100644
--- a/cmds/server.go
+++ b/cmds/server.go
@@ -109,9 +109,10 @@ 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}
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/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
+
+
+
+
+
+
+ Name
+ Slug
+ Posts
+
+
+
+
+ {{ range .categories }}
+
+ {{ .Name }}
+ {{ .Slug }}
+ {{ .PostCount }}
+
+ Edit
+
+
+ {{ else }}
+
+ No categories yet.
+
+ {{ end }}
+
+
+
+```
+
+Create `views/categories/edit.html`:
+
+```html
+
+
+
{{ if .isNew }}New Category{{ else }}Edit Category{{ end }}
+
+
+ {{ if .isNew }}
+
+
+ {{ 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
+
+ Categories
+
+```
+
+- [ ] **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 }}
+
+
+
+
+
+
+
+
+ {{ range .categories }}
+
+
+ {{ .Name }}
+
+ {{ 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"
+```
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..9c10abb
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-18-categories-design.md
@@ -0,0 +1,169 @@
+# 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,
+ updated_at INTEGER NOT NULL,
+ FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE
+);
+CREATE INDEX idx_categories_site ON categories (site_id);
+CREATE UNIQUE INDEX idx_categories_guid ON categories (guid);
+CREATE UNIQUE INDEX idx_categories_site_slug ON categories (site_id, slug);
+
+CREATE TABLE post_categories (
+ post_id INTEGER NOT NULL,
+ category_id INTEGER NOT NULL,
+ PRIMARY KEY (post_id, category_id),
+ FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE,
+ FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE
+);
+CREATE INDEX idx_post_categories_category ON post_categories (category_id);
+```
+
+### New Go Model (`models/categories.go`)
+
+```go
+type Category struct {
+ ID int64 `json:"id"`
+ SiteID int64 `json:"site_id"`
+ GUID string `json:"guid"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Description string `json:"description"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+```
+
+- `slug` is auto-generated from `name` (e.g. "Go Programming" -> `go-programming`), editable by the user.
+- `description` is Markdown, rendered on the category archive page. Defaults to empty string.
+- DB provider must use the existing `timeToInt()`/`time.Unix()` helpers for timestamp conversion, consistent with how posts are handled.
+
+## Admin UI
+
+### Category Management Page
+
+Route: `/sites/:siteID/categories`
+
+- Lists all categories for the site showing name, slug, and post count.
+- "New category" button navigates to a create/edit form.
+- Edit form fields: Name, Slug (auto-generated but editable), Description (Markdown textarea).
+- Delete button with confirmation. Deletes the category and its post associations; does not delete the posts.
+
+Handler: `CategoriesHandler` (new, in `handlers/categories.go`).
+Templates: `views/categories/index.html`, `views/categories/edit.html`.
+
+### Post Edit Form Changes
+
+- A multi-select checkbox list of all available categories (sorted alphabetically by name), displayed in a **right sidebar** alongside the main title/body editing area on the left.
+- Selected category IDs sent with the form submission.
+- `CreatePostParams` gains `CategoryIDs []int64`.
+
+### Post List (Admin)
+
+- Category names shown as small labels next to each post title.
+
+## Static Site Output
+
+### Category Index Page (`/categories/`)
+
+Lists all categories that have at least one published post. For each category:
+
+- Category name as a clickable link to the archive page
+- Post count
+- First sentence/line of the description as a brief excerpt
+
+### Category Archive Pages (`/categories//`)
+
+- 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 (`state = 0 AND deleted_at = 0`), ordered by `published_at` desc
+- `CountPostsOfCategory` — count of published posts per category (same `state = 0 AND deleted_at = 0` filter)
+- `InsertCategory` / `UpdateCategory` / `DeleteCategory` — CRUD
+- `InsertPostCategory` / `DeletePostCategory` — manage the join table
+- `DeletePostCategoriesByPost` — clear all categories for a post (delete-then-reinsert on save)
+
+## Service Layer
+
+### New `services/categories` Package
+
+`Service` struct with methods:
+
+- `ListCategories(ctx) ([]Category, error)` — all categories for the current site (from context)
+- `GetCategory(ctx, id) (*Category, error)`
+- `CreateCategory(ctx, params) (*Category, error)` — auto-generates slug from name. If the slug collides with an existing one for the same site, return a validation error.
+- `UpdateCategory(ctx, params) (*Category, error)` — same slug collision check on update.
+- `DeleteCategory(ctx, id) error` — deletes category and post associations, queues site rebuild
+
+All mutation methods verify site ownership (same pattern as post service authorization checks).
+
+### Changes to `services/posts`
+
+- `UpdatePost` — after saving the post, deletes existing `post_categories` rows and re-inserts for the selected category IDs. The post save and category reassignment must run within a single database transaction to ensure atomicity.
+- `GetPost` / `ListPosts` — loads each post's categories for admin display
+
+### Changes to Publishing Pipeline
+
+- `pubmodel.Site` gains new fields:
+ - `Categories []CategoryWithCount` — category list with post counts and description excerpts for the index page
+ - `PostIterByCategory func(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]]` — iterator for posts in a specific category
+- `sitebuilder.Builder.BuildSite` gains additional goroutines for:
+ - Rendering the category index page
+ - Rendering each category archive page
+ - Rendering per-category feeds
+- New templates: `tmplNameCategoryList`, `tmplNameCategorySingle` (must be added to the `ParseFS` call in `sitebuilder.New()`)
+- `postSingleData` gains a `Categories []Category` field so post templates can render category links
+
+### Rebuild Triggers
+
+Saving or deleting a category queues a site rebuild, same as post state changes.
+
+## DB Provider
+
+`providers/db/` gains wrapper methods for all new sqlc queries, following the same pattern as existing post methods (e.g. `SaveCategory`, `SelectCategoriesOfPost`, etc.).
+
+## Design Decisions
+
+- **Hard delete for categories** — unlike posts which use soft-delete, categories are hard-deleted. They are simpler entities and don't need a trash/restore workflow.
+- **No sort_order column** — categories are sorted alphabetically by name. Manual ordering can be added later if needed.
+- **Existing microblog-crosspost feed** — kept as-is. Per-category feeds are a separate, additive feature.
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/handlers/posts.go b/handlers/posts.go
index 3f282e0..a133758 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,16 @@ 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{},
+ "bodyClass": "post-edit-page",
})
}
@@ -62,11 +72,29 @@ 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,
+ "bodyClass": "post-edit-page",
})
}))
}
@@ -119,8 +147,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/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 944c5a1..0000000
--- a/layouts/simplecss/posts_list.html
+++ /dev/null
@@ -1,5 +0,0 @@
-{{ range .Posts }}
- {{ if .Post.Title }}{{ .Post.Title }} {{ end }}
- {{ .HTML }}
- {{ format_date .Post.PublishedAt }}
-{{ 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 5fd9fcb..0000000
--- a/layouts/simplecss/posts_single.html
+++ /dev/null
@@ -1,3 +0,0 @@
-{{ if .Post.Title }}{{ .Post.Title }} {{ end }}
-{{ .HTML }}
-{{ format_date .Post.PublishedAt }}
\ 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/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/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/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/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/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/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..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)
+ 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)
+ 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,12 +219,13 @@ 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: "",
}
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)
})
@@ -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: "",
}
@@ -283,6 +290,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()
diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go
index 4908d61..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)
+ 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,
@@ -62,7 +68,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 +82,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 +102,45 @@ 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)
+ })
+
+ // Build static assets
+ eg.Go(func() error { return b.writeStaticAssets(buildCtx) })
+
+ 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,6 +167,18 @@ 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
@@ -166,6 +189,8 @@ func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[*
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,
Updated: post.UpdatedAt,
@@ -243,14 +268,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
})
}
@@ -288,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
}
@@ -316,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/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..cea02f5 100644
--- a/providers/sitebuilder/tmpls.go
+++ b/providers/sitebuilder/tmpls.go
@@ -20,15 +20,26 @@ 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 {
- // 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
@@ -41,10 +52,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 +68,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/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/categories/service.go b/services/categories/service.go
new file mode 100644
index 0000000..c45280e
--- /dev/null
+++ b/services/categories/service.go
@@ -0,0 +1,178 @@
+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) {
+ 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) {
+ 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
+ } else if !db.ErrorIsNoRows(err) {
+ return nil, err
+ }
+
+ 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
+ } else if err != nil && !db.ErrorIsNoRows(err) {
+ return nil, err
+ }
+
+ 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
+}
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
-}
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/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..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"
@@ -46,6 +47,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 +75,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)
},
@@ -77,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/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/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 = ?;
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);
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 @@
Posts
+
+ Categories
+
Uploads
diff --git a/views/categories/edit.html b/views/categories/edit.html
new file mode 100644
index 0000000..c6c3606
--- /dev/null
+++ b/views/categories/edit.html
@@ -0,0 +1,47 @@
+
+
+
{{ if .isNew }}New Category{{ else }}Edit Category{{ end }}
+
+
+ {{ if .isNew }}
+
+ {{ else }}
+
+ {{ end }}
+
+
+
+
Slug
+
+
+
Auto-generated from name if left blank.
+
+
+
+
Description
+
+
{{ .category.Description }}
+
Markdown supported. Displayed on the category archive page.
+
+
+
+
+
+ {{ if .isNew }}Create{{ else }}Save{{ end }}
+ {{ if not .isNew }}
+ Delete
+ {{ end }}
+
+
+
+
+ {{ if not .isNew }}
+
+ {{ end }}
+
diff --git a/views/categories/index.html b/views/categories/index.html
new file mode 100644
index 0000000..2d17beb
--- /dev/null
+++ b/views/categories/index.html
@@ -0,0 +1,32 @@
+
+
+
+ {{ if .categories }}
+
+
+
+ Name
+ Slug
+ Posts
+
+
+
+ {{ range .categories }}
+
+ {{ .Name }}
+ {{ .Slug }}
+ {{ .PostCount }}
+
+ {{ end }}
+
+
+ {{ else }}
+
+ {{ 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 475c9a0..d162788 100644
--- a/views/posts/edit.html
+++ b/views/posts/edit.html
@@ -1,23 +1,42 @@
{{ $isPublished := ne .post.State 1 }}
-
-
-
-
-
-
- {{.post.Body}}
-
-
- {{ if $isPublished }}
-
- {{ else }}
-
-
- {{ end }}
+
+
+
+
+
+
+ {{ range .categories }}
+
+
+ {{ .Name }}
+
+ {{ 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..bbf445d 100644
--- a/views/posts/index.html
+++ b/views/posts/index.html
@@ -26,11 +26,11 @@
{{ 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 }}