diff --git a/assets/css/main.scss b/assets/css/main.scss
index dc6ad7d..c8f0344 100644
--- a/assets/css/main.scss
+++ b/assets/css/main.scss
@@ -10,7 +10,21 @@ $container-max-widths: (
@import "bootstrap/scss/bootstrap.scss";
-// Post list
+// 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%;
+}
.postlist .post img {
max-width: 300px;
@@ -18,49 +32,6 @@ $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 56517e7..40c2690 100644
--- a/cmds/server.go
+++ b/cmds/server.go
@@ -109,10 +109,9 @@ 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, CategoryService: svcs.Categories}
+ ph := handlers.PostsHandler{PostService: svcs.Posts}
uh := handlers.UploadsHandler{UploadsService: svcs.Uploads}
ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites}
- ch := handlers.CategoriesHandler{CategoryService: svcs.Categories}
app.Get("/login", lh.Login)
app.Post("/login", lh.DoLogin)
@@ -142,13 +141,6 @@ 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
deleted file mode 100644
index b4f3932..0000000
--- a/docs/superpowers/plans/2026-03-18-categories.md
+++ /dev/null
@@ -1,2036 +0,0 @@
-# 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
deleted file mode 100644
index 9c10abb..0000000
--- a/docs/superpowers/specs/2026-03-18-categories-design.md
+++ /dev/null
@@ -1,169 +0,0 @@
-# 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
deleted file mode 100644
index ec5e9ca..0000000
--- a/handlers/categories.go
+++ /dev/null
@@ -1,101 +0,0 @@
-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 a133758..3f282e0 100644
--- a/handlers/posts.go
+++ b/handlers/posts.go
@@ -6,13 +6,11 @@ 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
- CategoryService *categories.Service
+ PostService *posts.Service
}
func (ph PostsHandler) Index(c fiber.Ctx) error {
@@ -44,16 +42,8 @@ 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,
- "categories": cats,
- "selectedCategories": map[int64]bool{},
- "bodyClass": "post-edit-page",
+ "post": p,
})
}
@@ -72,29 +62,11 @@ 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,
- "categories": cats,
- "selectedCategories": selectedCategories,
- "bodyClass": "post-edit-page",
+ "post": post,
})
}))
}
@@ -147,7 +119,8 @@ 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", models.MustGetSite(c.Context()).ID))
+
+ return c.Redirect().To(fmt.Sprintf("/sites/%v/posts"))
}))
}
diff --git a/layouts/simplecss/fs.go b/layouts/simplecss/fs.go
index d82f6ae..2c1b2fb 100644
--- a/layouts/simplecss/fs.go
+++ b/layouts/simplecss/fs.go
@@ -2,6 +2,5 @@ package simplecss
import "embed"
-//go:embed templates/*.html
-//go:embed static/*
+//go:embed *.html
var FS embed.FS
diff --git a/layouts/simplecss/templates/layout_main.html b/layouts/simplecss/layout_main.html
similarity index 89%
rename from layouts/simplecss/templates/layout_main.html
rename to layouts/simplecss/layout_main.html
index 4aa5199..cc2e616 100644
--- a/layouts/simplecss/templates/layout_main.html
+++ b/layouts/simplecss/layout_main.html
@@ -7,7 +7,6 @@
-
diff --git a/layouts/simplecss/posts_list.html b/layouts/simplecss/posts_list.html
new file mode 100644
index 0000000..944c5a1
--- /dev/null
+++ b/layouts/simplecss/posts_list.html
@@ -0,0 +1,5 @@
+{{ 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
new file mode 100644
index 0000000..5fd9fcb
--- /dev/null
+++ b/layouts/simplecss/posts_single.html
@@ -0,0 +1,3 @@
+{{ 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
deleted file mode 100644
index cdfc4c2..0000000
--- a/layouts/simplecss/static/style.css
+++ /dev/null
@@ -1,55 +0,0 @@
-.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
deleted file mode 100644
index a042f41..0000000
--- a/layouts/simplecss/templates/_post_meta.html
+++ /dev/null
@@ -1,10 +0,0 @@
-
\ No newline at end of file
diff --git a/layouts/simplecss/templates/categories_list.html b/layouts/simplecss/templates/categories_list.html
deleted file mode 100644
index e5fc8c8..0000000
--- a/layouts/simplecss/templates/categories_list.html
+++ /dev/null
@@ -1,9 +0,0 @@
-Categories
-
-{{ range .Categories }}
-
- {{ .Name }} ({{ .PostCount }})
- {{ if .DescriptionBrief }}{{ .DescriptionBrief }} {{ end }}
-
-{{ end }}
-
\ No newline at end of file
diff --git a/layouts/simplecss/templates/categories_single.html b/layouts/simplecss/templates/categories_single.html
deleted file mode 100644
index deaeb02..0000000
--- a/layouts/simplecss/templates/categories_single.html
+++ /dev/null
@@ -1,11 +0,0 @@
-{{ .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/templates/posts_list.html b/layouts/simplecss/templates/posts_list.html
deleted file mode 100644
index 5f10f1e..0000000
--- a/layouts/simplecss/templates/posts_list.html
+++ /dev/null
@@ -1,8 +0,0 @@
-{{ 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
deleted file mode 100644
index 8895b19..0000000
--- a/layouts/simplecss/templates/posts_single.html
+++ /dev/null
@@ -1,5 +0,0 @@
-
- {{ 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
deleted file mode 100644
index 5655009..0000000
--- a/models/categories.go
+++ /dev/null
@@ -1,61 +0,0 @@
-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
deleted file mode 100644
index facf08b..0000000
--- a/models/categories_test.go
+++ /dev/null
@@ -1,28 +0,0 @@
-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 eda780c..997a952 100644
--- a/models/errors.go
+++ b/models/errors.go
@@ -7,4 +7,3 @@ 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 8d933fc..e57daf0 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) != 16 {
- t.Errorf("Expected ID length of 16, got %d", len(id))
+ if len(id) != 12 {
+ t.Errorf("Expected ID length of 12, got %d", len(id))
}
if id == "" {
diff --git a/models/pubmodel/sites.go b/models/pubmodel/sites.go
index a8862c4..a745885 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)
- 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)
+ 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]]
}
diff --git a/providers/db/categories.go b/providers/db/categories.go
deleted file mode 100644
index 72fac94..0000000
--- a/providers/db/categories.go
+++ /dev/null
@@ -1,132 +0,0 @@
-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
deleted file mode 100644
index d5bc40d..0000000
--- a/providers/db/gen/sqlgen/categories.sql.go
+++ /dev/null
@@ -1,305 +0,0 @@
-// 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 8eab959..7d9d9e7 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.28.0
+// sqlc v1.30.0
package sqlgen
diff --git a/providers/db/gen/sqlgen/models.go b/providers/db/gen/sqlgen/models.go
index 788c292..4f69bd0 100644
--- a/providers/db/gen/sqlgen/models.go
+++ b/providers/db/gen/sqlgen/models.go
@@ -1,20 +1,9 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.28.0
+// sqlc v1.30.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
@@ -40,11 +29,6 @@ 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 63eeb60..a831bbe 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.28.0
+// sqlc v1.30.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 8bff191..d512941 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.28.0
+// sqlc v1.30.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 69c09df..cd5cfa6 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.28.0
+// sqlc v1.30.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 bd80fb3..1a1b965 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.28.0
+// sqlc v1.30.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 189de2d..0433ae9 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.28.0
+// sqlc v1.30.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 6007589..a70a3bf 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.28.0
+// sqlc v1.30.0
// source: users.sql
package sqlgen
diff --git a/providers/db/provider.go b/providers/db/provider.go
index cc35225..eda0513 100644
--- a/providers/db/provider.go
+++ b/providers/db/provider.go
@@ -40,17 +40,6 @@ 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 06f03c0..4781d61 100644
--- a/providers/db/provider_test.go
+++ b/providers/db/provider_test.go
@@ -98,7 +98,6 @@ 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",
}
@@ -144,11 +143,10 @@ 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: guid,
+ GUID: "post-001",
Title: "First Post",
Body: "Hello world",
Slug: "/2026/02/19/first-post",
@@ -160,12 +158,12 @@ func TestProvider_Posts(t *testing.T) {
require.NoError(t, err)
assert.NotZero(t, post.ID)
- posts, err := p.SelectPostsOfSite(ctx, site.ID, false, db.PagingParams{Limit: 10, Offset: 0})
+ posts, err := p.SelectPostsOfSite(ctx, site.ID, false)
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, guid, posts[0].GUID)
+ assert.Equal(t, "post-001", 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)
@@ -175,10 +173,8 @@ 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: "",
}
@@ -189,7 +185,7 @@ func TestProvider_Posts(t *testing.T) {
post1 := &models.Post{
SiteID: site2.ID,
- GUID: guid,
+ GUID: "old-post",
Title: "Old Post",
Body: "old",
Slug: "/old",
@@ -198,7 +194,7 @@ func TestProvider_Posts(t *testing.T) {
}
post2 := &models.Post{
SiteID: site2.ID,
- GUID: models.NewNanoID(),
+ GUID: "new-post",
Title: "New Post",
Body: "new",
Slug: "/new",
@@ -209,7 +205,7 @@ func TestProvider_Posts(t *testing.T) {
require.NoError(t, p.SavePost(ctx, post1))
require.NoError(t, p.SavePost(ctx, post2))
- posts, err := p.SelectPostsOfSite(ctx, site2.ID, false, db.PagingParams{Limit: 10, Offset: 0})
+ posts, err := p.SelectPostsOfSite(ctx, site2.ID, false)
require.NoError(t, err)
require.Len(t, posts, 2)
assert.Equal(t, "New Post", posts[0].Title)
@@ -219,13 +215,12 @@ 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, db.PagingParams{})
+ posts, err := p.SelectPostsOfSite(ctx, emptySite.ID, false)
require.NoError(t, err)
assert.Empty(t, posts)
})
@@ -244,7 +239,6 @@ func TestProvider_PublishTargets(t *testing.T) {
site := &models.Site{
OwnerID: user.ID,
- GUID: models.NewNanoID(),
Title: "My Blog",
Tagline: "A test blog",
}
@@ -278,7 +272,6 @@ 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: "",
}
@@ -290,165 +283,6 @@ 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 1a4275d..4908d61 100644
--- a/providers/sitebuilder/builder.go
+++ b/providers/sitebuilder/builder.go
@@ -6,9 +6,7 @@ import (
"fmt"
"html/template"
"io"
- "io/fs"
"iter"
- "log"
"os"
"path/filepath"
"strings"
@@ -33,15 +31,11 @@ type Builder struct {
func New(site pubmodel.Site, opts Options) (*Builder, error) {
tmpls, err := template.New("").
Funcs(templateFns(site, opts)).
- ParseFS(opts.TemplatesFS, "*.html")
+ ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain)
if err != nil {
return nil, err
}
- for _, t := range tmpls.Templates() {
- log.Printf("Loaded template %s", t.Name())
- }
-
return &Builder{
site: site,
opts: opts,
@@ -68,13 +62,7 @@ func (b *Builder) BuildSite(outDir string) error {
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 {
+ if err := b.writePost(buildCtx, post); err != nil {
return err
}
}
@@ -82,7 +70,10 @@ func (b *Builder) BuildSite(outDir string) error {
})
eg.Go(func() error {
- return b.renderPostListWithCategories(buildCtx, ctx)
+ if err := b.renderPostList(buildCtx, b.site.PostIter(ctx)); err != nil {
+ return err
+ }
+ return nil
})
eg.Go(func() error {
@@ -102,45 +93,43 @@ func (b *Builder) BuildSite(outDir string) error {
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)
+ if err := b.writeUploads(buildCtx, b.site.Uploads); err != nil {
+ return err
+ }
+ return nil
})
+ if err := eg.Wait(); err != nil {
+ return err
+ }
- // Build static assets
- eg.Go(func() error { return b.writeStaticAssets(buildCtx) })
-
- return eg.Wait()
+ return nil
}
-func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Context) error {
- var posts []postSingleData
- for mp := range b.site.PostIter(ctx) {
+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 {
post, err := mp.Get()
if err != nil {
return err
}
- rp, err := b.renderPostWithCategories(ctx, post)
- if err != nil {
- return err
- }
- posts = append(posts, rp)
+ postCopy = append(postCopy, post)
}
pl := postListData{
commonData: commonData{Site: b.site},
- Posts: posts,
+ }
+ for _, post := range postCopy {
+ rp, err := b.renderPost(post)
+ if err != nil {
+ return err
+ }
+ pl.Posts = append(pl.Posts, rp)
}
- return b.createAtPath(bctx, "", func(f io.Writer) error {
+ return b.createAtPath(ctx, "", func(f io.Writer) error {
return b.renderTemplate(f, tmplNamePostList, pl)
})
}
@@ -167,18 +156,6 @@ 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
@@ -189,8 +166,6 @@ 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,
@@ -268,142 +243,14 @@ func (b *Builder) renderPost(post *models.Post) (postSingleData, error) {
}, nil
}
-// renderPostWithCategories renders a post and attaches its categories.
-func (b *Builder) renderPostWithCategories(ctx context.Context, post *models.Post) (postSingleData, error) {
+func (b *Builder) writePost(ctx buildContext, post *models.Post) 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, prefix+".json", func(f io.Writer) error {
- j, err := feed.ToJSON()
- if err != nil {
- return err
- }
- _, err = io.WriteString(f, j)
- return err
+ return b.createAtPath(ctx, rp.Path, func(f io.Writer) error {
+ return b.renderTemplate(f, tmplNamePostSingle, rp)
})
}
@@ -441,7 +288,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, b.opts.BaseUploads, u.Slug)
+ fullPath := filepath.Join(ctx.outDir, "uploads", u.Slug)
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
return err
}
@@ -469,37 +316,3 @@ 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 cbe116b..2564f6d 100644
--- a/providers/sitebuilder/builder_test.go
+++ b/providers/sitebuilder/builder_test.go
@@ -1,8 +1,6 @@
package sitebuilder_test
import (
- "context"
- "iter"
"os"
"path/filepath"
"testing"
@@ -17,36 +15,24 @@ 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 }}`)},
- "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",
- },
+ "posts_single.html": {Data: []byte(`{{ .HTML }}`)},
+ "posts_list.html": {Data: []byte(`{{ range .Posts}}{{.Post.Title}} ,{{ end }}`)},
+ "layout_main.html": {Data: []byte(`{{ .Body }}`)},
}
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
- }
- }
- }
+ 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",
+ },
},
}
wantFiles := map[string]string{
@@ -72,4 +58,5 @@ 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 cea02f5..fa70b6d 100644
--- a/providers/sitebuilder/tmpls.go
+++ b/providers/sitebuilder/tmpls.go
@@ -20,26 +20,15 @@ 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 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.
+ // BasePosts is the base path for posts.
+ BasePosts string
// 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
@@ -52,11 +41,10 @@ type commonData struct {
type postSingleData struct {
commonData
- Post *models.Post
- HTML template.HTML
- Path string
- PostURL string
- Categories []*models.Category
+ Post *models.Post
+ HTML template.HTML
+ Path string
+ PostURL string
}
type postListData struct {
@@ -68,21 +56,3 @@ 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
new file mode 100644
index 0000000..1365d4b
--- /dev/null
+++ b/providers/sitereader/provider.go
@@ -0,0 +1,94 @@
+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
new file mode 100644
index 0000000..0b012eb
--- /dev/null
+++ b/providers/sitereader/provider_test.go
@@ -0,0 +1,106 @@
+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
deleted file mode 100644
index c45280e..0000000
--- a/services/categories/service.go
+++ /dev/null
@@ -1,178 +0,0 @@
-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
new file mode 100644
index 0000000..e4aee94
--- /dev/null
+++ b/services/import/service.go
@@ -0,0 +1,54 @@
+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 b1a6466..f73d49c 100644
--- a/services/posts/create.go
+++ b/services/posts/create.go
@@ -10,11 +10,10 @@ 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"`
- CategoryIDs []int64 `form:"category_ids" json:"category_ids"`
+ GUID string `form:"guid" json:"guid"`
+ Title string `form:"title" json:"title"`
+ Body string `form:"body" json:"body"`
+ Action string `form:"action" json:"action"`
}
func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*models.Post, error) {
@@ -54,21 +53,7 @@ func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*mod
// Leave unchanged
}
- // 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 {
+ if err := s.db.SavePost(ctx, post); err != nil {
return nil, err
}
diff --git a/services/posts/list.go b/services/posts/list.go
index 15e14d3..ae70e1c 100644
--- a/services/posts/list.go
+++ b/services/posts/list.go
@@ -7,12 +7,7 @@ import (
"lmika.dev/lmika/weiro/providers/db"
)
-type PostWithCategories struct {
- *models.Post
- Categories []*models.Category
-}
-
-func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*PostWithCategories, error) {
+func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*models.Post, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
@@ -26,15 +21,7 @@ func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*PostWithC
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
+ return posts, nil
}
func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error) {
@@ -45,7 +32,3 @@ 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 ea70616..48b5252 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,26 +39,3 @@ 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 939817a..f0b39f0 100644
--- a/services/publisher/service.go
+++ b/services/publisher/service.go
@@ -3,7 +3,6 @@ package publisher
import (
"context"
"io"
- "io/fs"
"iter"
"log"
"os"
@@ -47,24 +46,6 @@ 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
@@ -75,15 +56,8 @@ 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,
- 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)
- },
+ BaseURL: target.BaseURL,
+ Uploads: uploads,
OpenUpload: func(u models.Upload) (io.ReadCloser, error) {
return p.up.OpenUpload(site, u)
},
@@ -103,22 +77,9 @@ 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",
- BaseUploads: "/uploads",
- BaseStatic: "/static",
- TemplatesFS: templateFS,
- StaticFS: staticFS,
+ TemplatesFS: simplecss.FS,
FeedItems: 30,
RenderTZ: renderTZ,
})
diff --git a/services/services.go b/services/services.go
index beb6727..606e932 100644
--- a/services/services.go
+++ b/services/services.go
@@ -7,7 +7,6 @@ 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"
@@ -22,7 +21,6 @@ type Services struct {
Posts *posts.Service
Sites *sites.Service
Uploads *uploads.Service
- Categories *categories.Service
}
func New(cfg config.Config) (*Services, error) {
@@ -39,7 +37,6 @@ 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,
@@ -49,7 +46,6 @@ 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
deleted file mode 100644
index 4b48506..0000000
--- a/sql/queries/categories.sql
+++ /dev/null
@@ -1,53 +0,0 @@
--- 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
deleted file mode 100644
index 260d06b..0000000
--- a/sql/schema/04_categories.up.sql
+++ /dev/null
@@ -1,23 +0,0 @@
-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 e8bce30..87801d2 100644
--- a/views/_common/nav.html
+++ b/views/_common/nav.html
@@ -10,9 +10,6 @@
Posts
-
- Categories
-
Uploads
diff --git a/views/categories/edit.html b/views/categories/edit.html
deleted file mode 100644
index c6c3606..0000000
--- a/views/categories/edit.html
+++ /dev/null
@@ -1,47 +0,0 @@
-
-
-
{{ 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
deleted file mode 100644
index 2d17beb..0000000
--- a/views/categories/index.html
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
- {{ 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 908094f..2b81177 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 d162788..475c9a0 100644
--- a/views/posts/edit.html
+++ b/views/posts/edit.html
@@ -1,42 +1,23 @@
{{ $isPublished := ne .post.State 1 }}
-
-
-
-
-
-
-
- {{ range .categories }}
-
-
- {{ .Name }}
-
- {{ else }}
-
No categories yet.
- {{ end }}
-
-
-
+
+
+
+
+
+ {{.post.Body}}
+
+
+ {{ if $isPublished }}
+
+ {{ else }}
+
+
+ {{ end }}
-
+
\ No newline at end of file
diff --git a/views/posts/index.html b/views/posts/index.html
index bbf445d..6470c24 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 $p.State 1 }}
-
{{ $.user.FormatTime $p.UpdatedAt }} Draft
+
+ {{ if eq .State 1 }}
+ {{ $.user.FormatTime .UpdatedAt }} Draft
{{ else }}
- {{ $.user.FormatTime $p.PublishedAt }}
+ {{ $.user.FormatTime .PublishedAt }}
{{ end }}