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 -
-
-

Categories

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

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

-
- - {{ if .isNew }} -
- {{ else }} - - {{ end }} - -
- -
- -
-
-
- -
- -
Auto-generated from name if left blank.
-
-
-
- -
- -
Markdown supported. Displayed on the category archive page.
-
-
-
-
-
- - {{ if not .isNew }} - - {{ end }} -
-
-
- - {{ if not .isNew }} - - {{ end }} -
-``` - -- [ ] **Step 4: Register routes in server.go** - -Add to `cmds/server.go` after the `ssh` handler initialization: - -```go -ch := handlers.CategoriesHandler{CategoryService: svcs.Categories} -``` - -Add routes in the `siteGroup` block (after the uploads routes): - -```go -siteGroup.Get("/categories", ch.Index) -siteGroup.Get("/categories/new", ch.New) -siteGroup.Get("/categories/:categoryID", ch.Edit) -siteGroup.Post("/categories", ch.Create) -siteGroup.Post("/categories/:categoryID", ch.Update) -siteGroup.Post("/categories/:categoryID/delete", ch.Delete) -``` - -- [ ] **Step 5: Add "Categories" link to admin nav** - -Modify `views/_common/nav.html` — add after the Posts nav item: - -```html - -``` - -- [ ] **Step 6: Verify the app compiles** - -Run: `go build ./...` -Expected: No errors (ignoring existing build issues in sitereader). - -- [ ] **Step 7: Commit** - -```bash -git add handlers/categories.go views/categories/ views/_common/nav.html services/services.go cmds/server.go -git commit -m "feat: add categories admin UI with CRUD" -``` - ---- - -## Task 6: Post Edit Form — Category Sidebar - -**Files:** -- Modify: `views/posts/edit.html` -- Modify: `handlers/posts.go` -- Modify: `services/posts/create.go` -- Modify: `services/posts/list.go` - -- [ ] **Step 1: Pass categories to the post edit handler** - -Modify `handlers/posts.go` — add `CategoryService` field to `PostsHandler`: - -```go -type PostsHandler struct { - PostService *posts.Service - CategoryService *categories.Service -} -``` - -Add the import for `"lmika.dev/lmika/weiro/services/categories"`. - -In the `New` method, fetch categories and pass them along with selected IDs (empty for new post): - -```go -func (ph PostsHandler) New(c fiber.Ctx) error { - p := models.Post{ - GUID: models.NewNanoID(), - State: models.StateDraft, - } - - cats, err := ph.CategoryService.ListCategories(c.Context()) - if err != nil { - return err - } - - return c.Render("posts/edit", fiber.Map{ - "post": p, - "categories": cats, - "selectedCategories": map[int64]bool{}, - }) -} -``` - -In the `Edit` method, fetch categories and the post's current categories: - -```go -func (ph PostsHandler) Edit(c fiber.Ctx) error { - postIDStr := c.Params("postID") - if postIDStr == "" { - return fiber.ErrBadRequest - } - postID, err := strconv.ParseInt(postIDStr, 10, 64) - if err != nil { - return fiber.ErrBadRequest - } - - post, err := ph.PostService.GetPost(c.Context(), postID) - if err != nil { - return err - } - - cats, err := ph.CategoryService.ListCategories(c.Context()) - if err != nil { - return err - } - - postCats, err := ph.PostService.GetPostCategories(c.Context(), postID) - if err != nil { - return err - } - - selectedCategories := make(map[int64]bool) - for _, pc := range postCats { - selectedCategories[pc.ID] = true - } - - return accepts(c, json(func() any { - return post - }), html(func(c fiber.Ctx) error { - return c.Render("posts/edit", fiber.Map{ - "post": post, - "categories": cats, - "selectedCategories": selectedCategories, - }) - })) -} -``` - -- [ ] **Step 2: Add CategoryIDs to CreatePostParams and update service** - -Modify `services/posts/create.go` — add to `CreatePostParams`: - -```go -CategoryIDs []int64 `form:"category_ids" json:"category_ids"` -``` - -Add `GetPostCategories` method to `services/posts/list.go`: - -```go -func (s *Service) GetPostCategories(ctx context.Context, postID int64) ([]*models.Category, error) { - return s.db.SelectCategoriesOfPost(ctx, postID) -} -``` - -Wrap the post save and category assignment in a transaction. Replace the `s.db.SavePost(ctx, post)` call and add category handling: - -```go -// Use a transaction for atomicity of post save + category reassignment -tx, err := s.db.BeginTx(ctx) -if err != nil { - return nil, err -} -defer tx.Rollback() - -txDB := s.db.QueriesWithTx(tx) -if err := txDB.SavePost(ctx, post); err != nil { - return nil, err -} -if err := txDB.SetPostCategories(ctx, post.ID, params.CategoryIDs); err != nil { - return nil, err -} -if err := tx.Commit(); err != nil { - return nil, err -} -``` - -This replaces the existing non-transactional `s.db.SavePost(ctx, post)` call. - -- [ ] **Step 3: Wire CategoryService into PostsHandler in server.go** - -Modify the `ph` initialization in `cmds/server.go`: - -```go -ph := handlers.PostsHandler{PostService: svcs.Posts, CategoryService: svcs.Categories} -``` - -- [ ] **Step 4: Update the post edit template with category sidebar** - -Replace the content of `views/posts/edit.html` with: - -```html -{{ $isPublished := ne .post.State 1 }} -
-
-
-
- -
- -
-
- -
-
- {{ if $isPublished }} - - {{ else }} - - - {{ end }} -
-
-
-
-
Categories
-
- {{ range .categories }} -
- - -
- {{ else }} - No categories yet. - {{ end }} -
-
-
-
-
-
-``` - -- [ ] **Step 5: Show category badges on post list** - -Modify `services/posts/list.go` — update `ListPosts` to return posts with categories. Add a new type: - -```go -type PostWithCategories struct { - *models.Post - Categories []*models.Category -} -``` - -Update `ListPosts` to return `[]*PostWithCategories`: - -```go -func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*PostWithCategories, error) { - site, ok := models.GetSite(ctx) - if !ok { - return nil, models.SiteRequiredError - } - - posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, db.PagingParams{ - Offset: 0, - Limit: 25, - }) - if err != nil { - return nil, err - } - - result := make([]*PostWithCategories, len(posts)) - for i, post := range posts { - cats, err := s.db.SelectCategoriesOfPost(ctx, post.ID) - if err != nil { - return nil, err - } - result[i] = &PostWithCategories{Post: post, Categories: cats} - } - return result, nil -} -``` - -Update `views/posts/index.html` — after the Draft badge or date line (inside the `.mb-3.d-flex` div), add category badges. Replace the date/badge div: - -```html -
- {{ if eq .State 1 }} - {{ $.user.FormatTime .UpdatedAt }} Draft - {{ else }} - {{ $.user.FormatTime .PublishedAt }} - {{ end }} - {{ range .Categories }} - {{ .Name }} - {{ end }} -
-``` - -Update the handler `Index` method in `handlers/posts.go` — the template variable `posts` stays the same but each item now has a `.Categories` field. - -- [ ] **Step 6: Verify the app compiles** - -Run: `go build ./...` -Expected: No errors. - -- [ ] **Step 7: Commit** - -```bash -git add handlers/posts.go services/posts/create.go services/posts/list.go views/posts/edit.html views/posts/index.html cmds/server.go -git commit -m "feat: add category selection to post edit form and badges to post list" -``` - ---- - -## Task 7: Site Builder — Category Pages + Feeds - -**Files:** -- Modify: `models/pubmodel/sites.go` -- Modify: `providers/sitebuilder/tmpls.go` -- Modify: `providers/sitebuilder/builder.go` -- Create: `layouts/simplecss/categories_list.html` -- Create: `layouts/simplecss/categories_single.html` -- Modify: `services/publisher/service.go` -- Modify: `services/publisher/iter.go` - -- [ ] **Step 1: Extend pubmodel.Site** - -Modify `models/pubmodel/sites.go`: - -```go -package pubmodel - -import ( - "context" - "io" - "iter" - - "lmika.dev/lmika/weiro/models" -) - -type Site struct { - models.Site - BaseURL string - Uploads []models.Upload - - OpenUpload func(u models.Upload) (io.ReadCloser, error) - PostIter func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] - Categories []models.CategoryWithCount - PostIterByCategory func(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]] - CategoriesOfPost func(ctx context.Context, postID int64) ([]*models.Category, error) -} -``` - -- [ ] **Step 2: Add template data structs and template names** - -Add to `providers/sitebuilder/tmpls.go`: - -```go -const ( - tmplNameCategoryList = "categories_list.html" - tmplNameCategorySingle = "categories_single.html" -) - -type categoryListData struct { - commonData - Categories []categoryListItem -} - -type categoryListItem struct { - models.CategoryWithCount - Path string -} - -type categorySingleData struct { - commonData - Category *models.Category - DescriptionHTML template.HTML - Posts []postSingleData - Path string -} -``` - -Add to the `postSingleData` struct: - -```go -Categories []*models.Category -``` - -Add the import for `"lmika.dev/lmika/weiro/models"` if not already present. - -- [ ] **Step 3: Create the published site category templates** - -Create `layouts/simplecss/categories_list.html`: - -```html -

Categories

- -``` - -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 @@ - 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 }} - -
- -
- -
-
-
- -
- -
Auto-generated from name if left blank.
-
-
-
- -
- -
Markdown supported. Displayed on the category archive page.
-
-
-
-
-
- - {{ if not .isNew }} - - {{ end }} -
-
-
- - {{ if not .isNew }} - - {{ end }} -
diff --git a/views/categories/index.html b/views/categories/index.html deleted file mode 100644 index 2d17beb..0000000 --- a/views/categories/index.html +++ /dev/null @@ -1,32 +0,0 @@ -
-
- -
- - {{ if .categories }} - - - - - - - - - - {{ range .categories }} - - - - - - {{ end }} - -
NameSlugPosts
{{ .Name }}{{ .Slug }}{{ .PostCount }}
- {{ else }} -
-
📚
No categories yet.
-
- {{ 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 }}
-
-
-
- -
- -
- -
- {{ if $isPublished }} - - {{ else }} - - - {{ end }} -
-
-
-
-
Categories
-
- {{ range .categories }} -
- - -
- {{ else }} - No categories yet. - {{ end }} -
-
-
+ +
+ +
+
+ +
+
+ {{ 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 }} - Draft +
+ {{ if eq .State 1 }} + {{ $.user.FormatTime .UpdatedAt }} Draft {{ else }} - + {{ $.user.FormatTime .PublishedAt }} {{ end }}