Merge pull request 'Add categories feature' (#3) from feature/categories into main

Reviewed-on: #3
This commit is contained in:
lmika 2026-03-21 23:29:04 +00:00
commit fa676cfdf7
53 changed files with 4000 additions and 389 deletions

View file

@ -10,21 +10,7 @@ $container-max-widths: (
@import "bootstrap/scss/bootstrap.scss";
// Local classes
.post-form {
display: grid;
grid-template-rows: min-content auto min-content;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.post-form textarea {
height: 100%;
}
// Post list
.postlist .post img {
max-width: 300px;
@ -32,6 +18,49 @@ $container-max-widths: (
max-height: 300px;
}
.postlist .post-date {
font-size: 0.9rem;
}
// Post form
// Post edit page styling
.post-edit-page {
height: 100vh;
}
.post-edit-page main {
display: flex;
flex-direction: column;
overflow: hidden;
}
.post-edit-page .post-form {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.post-edit-page .post-form .row {
flex: 1;
display: flex;
min-height: 0;
}
.post-edit-page .post-form .col-md-9 {
display: flex;
flex-direction: column;
}
.post-edit-page .post-form textarea {
flex: 1;
resize: vertical;
min-height: 300px;
}
.show-upload figure img {
max-width: 100vw;
height: auto;

View file

@ -109,9 +109,10 @@ Starting weiro without any arguments will start the server.
ih := handlers.IndexHandler{SiteService: svcs.Sites}
lh := handlers.LoginHandler{Config: cfg, AuthService: svcs.Auth}
ph := handlers.PostsHandler{PostService: svcs.Posts}
ph := handlers.PostsHandler{PostService: svcs.Posts, CategoryService: svcs.Categories}
uh := handlers.UploadsHandler{UploadsService: svcs.Uploads}
ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites}
ch := handlers.CategoriesHandler{CategoryService: svcs.Categories}
app.Get("/login", lh.Login)
app.Post("/login", lh.DoLogin)
@ -141,6 +142,13 @@ Starting weiro without any arguments will start the server.
siteGroup.Get("/settings", ssh.General)
siteGroup.Post("/settings", ssh.UpdateGeneral)
siteGroup.Get("/categories", ch.Index)
siteGroup.Get("/categories/new", ch.New)
siteGroup.Get("/categories/:categoryID", ch.Edit)
siteGroup.Post("/categories", ch.Create)
siteGroup.Post("/categories/:categoryID", ch.Update)
siteGroup.Post("/categories/:categoryID/delete", ch.Delete)
app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index)
app.Get("/first-run", ih.FirstRun)
app.Post("/first-run", ih.FirstRunSubmit)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,169 @@
# Categories Feature Design
## Overview
Add flat, many-to-many categories to Weiro. Categories are managed via a dedicated admin page and assigned to posts on the post edit form. On the published static site, categories appear as labels on posts, archive pages per category, a category index page, and per-category RSS/JSON feeds. Categories with no published posts are hidden from the published site.
## Data Model
### New Tables (migration `04_categories.up.sql`)
```sql
CREATE TABLE categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL,
guid TEXT NOT NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE
);
CREATE INDEX idx_categories_site ON categories (site_id);
CREATE UNIQUE INDEX idx_categories_guid ON categories (guid);
CREATE UNIQUE INDEX idx_categories_site_slug ON categories (site_id, slug);
CREATE TABLE post_categories (
post_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
PRIMARY KEY (post_id, category_id),
FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE
);
CREATE INDEX idx_post_categories_category ON post_categories (category_id);
```
### New Go Model (`models/categories.go`)
```go
type Category struct {
ID int64 `json:"id"`
SiteID int64 `json:"site_id"`
GUID string `json:"guid"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
```
- `slug` is auto-generated from `name` (e.g. "Go Programming" -> `go-programming`), editable by the user.
- `description` is Markdown, rendered on the category archive page. Defaults to empty string.
- DB provider must use the existing `timeToInt()`/`time.Unix()` helpers for timestamp conversion, consistent with how posts are handled.
## Admin UI
### Category Management Page
Route: `/sites/:siteID/categories`
- Lists all categories for the site showing name, slug, and post count.
- "New category" button navigates to a create/edit form.
- Edit form fields: Name, Slug (auto-generated but editable), Description (Markdown textarea).
- Delete button with confirmation. Deletes the category and its post associations; does not delete the posts.
Handler: `CategoriesHandler` (new, in `handlers/categories.go`).
Templates: `views/categories/index.html`, `views/categories/edit.html`.
### Post Edit Form Changes
- A multi-select checkbox list of all available categories (sorted alphabetically by name), displayed in a **right sidebar** alongside the main title/body editing area on the left.
- Selected category IDs sent with the form submission.
- `CreatePostParams` gains `CategoryIDs []int64`.
### Post List (Admin)
- Category names shown as small labels next to each post title.
## Static Site Output
### Category Index Page (`/categories/`)
Lists all categories that have at least one published post. For each category:
- Category name as a clickable link to the archive page
- Post count
- First sentence/line of the description as a brief excerpt
### Category Archive Pages (`/categories/<slug>/`)
- 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/<slug>/feed.xml` (RSS)
- `/categories/<slug>/feed.json` (JSON Feed)
Main site feeds (`/feed.xml`, `/feed.json`) gain category metadata on each post entry.
### Empty Category Handling
Categories with no published posts are hidden from the published site: no index entry, no archive page, no feed generated. They remain visible and manageable in the admin UI.
## SQL Queries
New file: `sql/queries/categories.sql`
- `SelectCategoriesOfSite` — all categories for a site, ordered by 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.

101
handlers/categories.go Normal file
View file

@ -0,0 +1,101 @@
package handlers
import (
"fmt"
"strconv"
"github.com/gofiber/fiber/v3"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/services/categories"
)
type CategoriesHandler struct {
CategoryService *categories.Service
}
func (ch CategoriesHandler) Index(c fiber.Ctx) error {
cats, err := ch.CategoryService.ListCategoriesWithCounts(c.Context())
if err != nil {
return err
}
return c.Render("categories/index", fiber.Map{
"categories": cats,
})
}
func (ch CategoriesHandler) New(c fiber.Ctx) error {
cat := models.Category{
GUID: models.NewNanoID(),
}
return c.Render("categories/edit", fiber.Map{
"category": cat,
"isNew": true,
})
}
func (ch CategoriesHandler) Edit(c fiber.Ctx) error {
catID, err := strconv.ParseInt(c.Params("categoryID"), 10, 64)
if err != nil {
return fiber.ErrBadRequest
}
cat, err := ch.CategoryService.GetCategory(c.Context(), catID)
if err != nil {
return err
}
return c.Render("categories/edit", fiber.Map{
"category": cat,
"isNew": false,
})
}
func (ch CategoriesHandler) Create(c fiber.Ctx) error {
var req categories.CreateCategoryParams
if err := c.Bind().Body(&req); err != nil {
return err
}
_, err := ch.CategoryService.CreateCategory(c.Context(), req)
if err != nil {
return err
}
site := models.MustGetSite(c.Context())
return c.Redirect().To(fmt.Sprintf("/sites/%v/categories", site.ID))
}
func (ch CategoriesHandler) Update(c fiber.Ctx) error {
catID, err := strconv.ParseInt(c.Params("categoryID"), 10, 64)
if err != nil {
return fiber.ErrBadRequest
}
var req categories.CreateCategoryParams
if err := c.Bind().Body(&req); err != nil {
return err
}
_, err = ch.CategoryService.UpdateCategory(c.Context(), catID, req)
if err != nil {
return err
}
site := models.MustGetSite(c.Context())
return c.Redirect().To(fmt.Sprintf("/sites/%v/categories", site.ID))
}
func (ch CategoriesHandler) Delete(c fiber.Ctx) error {
catID, err := strconv.ParseInt(c.Params("categoryID"), 10, 64)
if err != nil {
return fiber.ErrBadRequest
}
if err := ch.CategoryService.DeleteCategory(c.Context(), catID); err != nil {
return err
}
site := models.MustGetSite(c.Context())
return c.Redirect().To(fmt.Sprintf("/sites/%v/categories", site.ID))
}

View file

@ -6,11 +6,13 @@ import (
"github.com/gofiber/fiber/v3"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/services/categories"
"lmika.dev/lmika/weiro/services/posts"
)
type PostsHandler struct {
PostService *posts.Service
CategoryService *categories.Service
}
func (ph PostsHandler) Index(c fiber.Ctx) error {
@ -42,8 +44,16 @@ func (ph PostsHandler) New(c fiber.Ctx) error {
State: models.StateDraft,
}
cats, err := ph.CategoryService.ListCategories(c.Context())
if err != nil {
return err
}
return c.Render("posts/edit", fiber.Map{
"post": p,
"categories": cats,
"selectedCategories": map[int64]bool{},
"bodyClass": "post-edit-page",
})
}
@ -62,11 +72,29 @@ func (ph PostsHandler) Edit(c fiber.Ctx) error {
return err
}
cats, err := ph.CategoryService.ListCategories(c.Context())
if err != nil {
return err
}
postCats, err := ph.PostService.GetPostCategories(c.Context(), postID)
if err != nil {
return err
}
selectedCategories := make(map[int64]bool)
for _, pc := range postCats {
selectedCategories[pc.ID] = true
}
return accepts(c, json(func() any {
return post
}), html(func(c fiber.Ctx) error {
return c.Render("posts/edit", fiber.Map{
"post": post,
"categories": cats,
"selectedCategories": selectedCategories,
"bodyClass": "post-edit-page",
})
}))
}
@ -119,8 +147,7 @@ func (ph PostsHandler) Patch(c fiber.Ctx) error {
return accepts(c, json(func() any {
return struct{}{}
}), html(func(c fiber.Ctx) error {
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts"))
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", models.MustGetSite(c.Context()).ID))
}))
}

View file

@ -2,5 +2,6 @@ package simplecss
import "embed"
//go:embed *.html
//go:embed templates/*.html
//go:embed static/*
var FS embed.FS

View file

@ -1,5 +0,0 @@
{{ range .Posts }}
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
{{ .HTML }}
<a href="{{ url_abs .Path }}">{{ format_date .Post.PublishedAt }}</a>
{{ end }}

View file

@ -1,3 +0,0 @@
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
{{ .HTML }}
<a href="{{ url_abs .Path }}">{{ format_date .Post.PublishedAt }}</a>

View file

@ -0,0 +1,55 @@
.h-entry {
margin-block-start: 1.5rem;
margin-block-end: 2.5rem;
}
.post-meta {
display: flex;
flex-direction: row;
justify-content: space-between;
font-size: 0.95rem;
}
.post-meta a {
color: var(--text-light);
text-decoration: none;
}
.post-meta a:hover {
text-decoration: underline;
}
.post-categories {
display: inline-flex;
gap: 0.5rem;
}
.post-categories a:before {
content: "#";
}
/* Category list */
ul.category-list {
list-style: none;
padding-inline-start: 0;
}
ul.category-list li {
display: flex;
flex-direction: row;
justify-content: start;
gap: 4rem;
}
ul.category-list span.category-list-name {
min-width: 15vw;
}
/* Category single */
.category-description {
margin-block-start: 1.5rem;
margin-block-end: 2.5rem;
}

View file

@ -0,0 +1,10 @@
<div class="post-meta">
<a href="{{ url_abs .Path }}">{{ format_date .Post.PublishedAt }}</a>
{{ if .Categories }}
<div class="post-categories">
{{ range .Categories }}
<a href="{{ url_abs (printf "/categories/%s" .Slug) }}">{{ .Name }}</a>
{{ end }}
</div>
{{ end }}
</div>

View file

@ -0,0 +1,9 @@
<h2>Categories</h2>
<ul class="category-list">
{{ range .Categories }}
<li>
<span class="category-list-name"><a href="{{ url_abs .Path }}">{{ .Name }}</a> ({{ .PostCount }})</span>
{{ if .DescriptionBrief }}<small>{{ .DescriptionBrief }}</small>{{ end }}
</li>
{{ end }}
</ul>

View file

@ -0,0 +1,11 @@
<h2>{{ .Category.Name }}</h2>
{{ if .DescriptionHTML }}
<div class="notice category-description">{{ .DescriptionHTML }}</div>
{{ end }}
{{ range .Posts }}
<div class="h-entry">
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
{{ .HTML }}
{{ template "_post_meta.html" . }}
</div>
{{ end }}

View file

@ -7,6 +7,7 @@
<link rel="alternate" type="application/rss+xml" title="RSS Feed" href="{{ url_abs "/feed.xml" }}"/>
<link rel="alternate" type="application/json" title="JSON feed" href="{{ url_abs "/feed.json" }}"/>
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
<link rel="stylesheet" href="{{ url_abs "/static/style.css" }}">
</head>
<body>
<header>

View file

@ -0,0 +1,8 @@
{{ range .Posts }}
<div class="h-entry">
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
{{ .HTML }}
{{ template "_post_meta.html" . }}
</div>
{{ end }}

View file

@ -0,0 +1,5 @@
<div class="h-entry">
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
{{ .HTML }}
{{ template "_post_meta.html" . }}
</div>

61
models/categories.go Normal file
View file

@ -0,0 +1,61 @@
package models
import (
"strings"
"time"
"unicode"
)
type Category struct {
ID int64 `json:"id"`
SiteID int64 `json:"site_id"`
GUID string `json:"guid"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CategoryWithCount is a Category plus the count of published posts in it.
type CategoryWithCount struct {
Category
PostCount int
DescriptionBrief string
}
// GenerateCategorySlug creates a URL-safe slug from a category name.
// e.g. "Go Programming" -> "go-programming"
func GenerateCategorySlug(name string) string {
var sb strings.Builder
prevDash := false
for _, c := range strings.TrimSpace(name) {
if unicode.IsLetter(c) || unicode.IsNumber(c) {
sb.WriteRune(unicode.ToLower(c))
prevDash = false
} else if unicode.IsSpace(c) || c == '-' || c == '_' {
if !prevDash && sb.Len() > 0 {
sb.WriteRune('-')
prevDash = true
}
}
}
result := sb.String()
return strings.TrimRight(result, "-")
}
// BriefDescription returns the first sentence or line of the description.
func BriefDescription(desc string) string {
if desc == "" {
return ""
}
for i, c := range desc {
if c == '\n' {
return desc[:i]
}
if c == '.' && i+1 < len(desc) {
return desc[:i+1]
}
}
return desc
}

28
models/categories_test.go Normal file
View file

@ -0,0 +1,28 @@
package models_test
import (
"testing"
"github.com/stretchr/testify/assert"
"lmika.dev/lmika/weiro/models"
)
func TestGenerateCategorySlug(t *testing.T) {
tests := []struct {
name string
want string
}{
{"Go Programming", "go-programming"},
{" Travel ", "travel"},
{"hello---world", "hello-world"},
{"UPPER CASE", "upper-case"},
{"one", "one"},
{"with_underscores", "with-underscores"},
{"special!@#chars", "specialchars"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, models.GenerateCategorySlug(tt.name))
})
}
}

View file

@ -7,3 +7,4 @@ var PermissionError = errors.New("permission denied")
var NotFoundError = errors.New("not found")
var SiteRequiredError = errors.New("site required")
var DeleteDebounceError = errors.New("permanent delete too soon, try again in a few seconds")
var SlugConflictError = errors.New("a category with this slug already exists")

View file

@ -7,8 +7,8 @@ import (
func TestNewNanoID(t *testing.T) {
id := NewNanoID()
if len(id) != 12 {
t.Errorf("Expected ID length of 12, got %d", len(id))
if len(id) != 16 {
t.Errorf("Expected ID length of 16, got %d", len(id))
}
if id == "" {

View file

@ -11,11 +11,11 @@ import (
type Site struct {
models.Site
BaseURL string
//Posts []*models.Post
Uploads []models.Upload
OpenUpload func(u models.Upload) (io.ReadCloser, error)
// PostItr returns a new post iterator
PostIter func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]]
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)
}

132
providers/db/categories.go Normal file
View file

@ -0,0 +1,132 @@
package db
import (
"context"
"time"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
)
func (db *Provider) SelectCategoriesOfSite(ctx context.Context, siteID int64) ([]*models.Category, error) {
rows, err := db.queries.SelectCategoriesOfSite(ctx, siteID)
if err != nil {
return nil, err
}
cats := make([]*models.Category, len(rows))
for i, row := range rows {
cats[i] = dbCategoryToCategory(row)
}
return cats, nil
}
func (db *Provider) SelectCategory(ctx context.Context, id int64) (*models.Category, error) {
row, err := db.queries.SelectCategory(ctx, id)
if err != nil {
return nil, err
}
return dbCategoryToCategory(row), nil
}
func (db *Provider) SelectCategoryBySlugAndSite(ctx context.Context, siteID int64, slug string) (*models.Category, error) {
row, err := db.queries.SelectCategoryBySlugAndSite(ctx, sqlgen.SelectCategoryBySlugAndSiteParams{
SiteID: siteID,
Slug: slug,
})
if err != nil {
return nil, err
}
return dbCategoryToCategory(row), nil
}
func (db *Provider) SaveCategory(ctx context.Context, cat *models.Category) error {
if cat.ID == 0 {
newID, err := db.queries.InsertCategory(ctx, sqlgen.InsertCategoryParams{
SiteID: cat.SiteID,
Guid: cat.GUID,
Name: cat.Name,
Slug: cat.Slug,
Description: cat.Description,
CreatedAt: timeToInt(cat.CreatedAt),
UpdatedAt: timeToInt(cat.UpdatedAt),
})
if err != nil {
return err
}
cat.ID = newID
return nil
}
return db.queries.UpdateCategory(ctx, sqlgen.UpdateCategoryParams{
ID: cat.ID,
Name: cat.Name,
Slug: cat.Slug,
Description: cat.Description,
UpdatedAt: timeToInt(cat.UpdatedAt),
})
}
func (db *Provider) DeleteCategory(ctx context.Context, id int64) error {
return db.queries.DeleteCategory(ctx, id)
}
func (db *Provider) SelectCategoriesOfPost(ctx context.Context, postID int64) ([]*models.Category, error) {
rows, err := db.queries.SelectCategoriesOfPost(ctx, postID)
if err != nil {
return nil, err
}
cats := make([]*models.Category, len(rows))
for i, row := range rows {
cats[i] = dbCategoryToCategory(row)
}
return cats, nil
}
func (db *Provider) SelectPostsOfCategory(ctx context.Context, categoryID int64, pp PagingParams) ([]*models.Post, error) {
rows, err := db.queries.SelectPostsOfCategory(ctx, sqlgen.SelectPostsOfCategoryParams{
CategoryID: categoryID,
Limit: pp.Limit,
Offset: pp.Offset,
})
if err != nil {
return nil, err
}
posts := make([]*models.Post, len(rows))
for i, row := range rows {
posts[i] = dbPostToPost(row)
}
return posts, nil
}
func (db *Provider) CountPostsOfCategory(ctx context.Context, categoryID int64) (int64, error) {
return db.queries.CountPostsOfCategory(ctx, categoryID)
}
// SetPostCategories replaces all category associations for a post.
func (db *Provider) SetPostCategories(ctx context.Context, postID int64, categoryIDs []int64) error {
if err := db.queries.DeletePostCategoriesByPost(ctx, postID); err != nil {
return err
}
for _, catID := range categoryIDs {
if err := db.queries.InsertPostCategory(ctx, sqlgen.InsertPostCategoryParams{
PostID: postID,
CategoryID: catID,
}); err != nil {
return err
}
}
return nil
}
func dbCategoryToCategory(row sqlgen.Category) *models.Category {
return &models.Category{
ID: row.ID,
SiteID: row.SiteID,
GUID: row.Guid,
Name: row.Name,
Slug: row.Slug,
Description: row.Description,
CreatedAt: time.Unix(row.CreatedAt, 0).UTC(),
UpdatedAt: time.Unix(row.UpdatedAt, 0).UTC(),
}
}

View file

@ -0,0 +1,305 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: categories.sql
package sqlgen
import (
"context"
)
const countPostsOfCategory = `-- name: CountPostsOfCategory :one
SELECT COUNT(*) FROM posts p
INNER JOIN post_categories pc ON pc.post_id = p.id
WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0
`
func (q *Queries) CountPostsOfCategory(ctx context.Context, categoryID int64) (int64, error) {
row := q.db.QueryRowContext(ctx, countPostsOfCategory, categoryID)
var count int64
err := row.Scan(&count)
return count, err
}
const deleteCategory = `-- name: DeleteCategory :exec
DELETE FROM categories WHERE id = ?
`
func (q *Queries) DeleteCategory(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, deleteCategory, id)
return err
}
const deletePostCategoriesByPost = `-- name: DeletePostCategoriesByPost :exec
DELETE FROM post_categories WHERE post_id = ?
`
func (q *Queries) DeletePostCategoriesByPost(ctx context.Context, postID int64) error {
_, err := q.db.ExecContext(ctx, deletePostCategoriesByPost, postID)
return err
}
const insertCategory = `-- name: InsertCategory :one
INSERT INTO categories (
site_id, guid, name, slug, description, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id
`
type InsertCategoryParams struct {
SiteID int64
Guid string
Name string
Slug string
Description string
CreatedAt int64
UpdatedAt int64
}
func (q *Queries) InsertCategory(ctx context.Context, arg InsertCategoryParams) (int64, error) {
row := q.db.QueryRowContext(ctx, insertCategory,
arg.SiteID,
arg.Guid,
arg.Name,
arg.Slug,
arg.Description,
arg.CreatedAt,
arg.UpdatedAt,
)
var id int64
err := row.Scan(&id)
return id, err
}
const insertPostCategory = `-- name: InsertPostCategory :exec
INSERT OR IGNORE INTO post_categories (post_id, category_id) VALUES (?, ?)
`
type InsertPostCategoryParams struct {
PostID int64
CategoryID int64
}
func (q *Queries) InsertPostCategory(ctx context.Context, arg InsertPostCategoryParams) error {
_, err := q.db.ExecContext(ctx, insertPostCategory, arg.PostID, arg.CategoryID)
return err
}
const selectCategoriesOfPost = `-- name: SelectCategoriesOfPost :many
SELECT c.id, c.site_id, c.guid, c.name, c.slug, c.description, c.created_at, c.updated_at FROM categories c
INNER JOIN post_categories pc ON pc.category_id = c.id
WHERE pc.post_id = ?
ORDER BY c.name ASC
`
func (q *Queries) SelectCategoriesOfPost(ctx context.Context, postID int64) ([]Category, error) {
rows, err := q.db.QueryContext(ctx, selectCategoriesOfPost, postID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Category
for rows.Next() {
var i Category
if err := rows.Scan(
&i.ID,
&i.SiteID,
&i.Guid,
&i.Name,
&i.Slug,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const selectCategoriesOfSite = `-- name: SelectCategoriesOfSite :many
SELECT id, site_id, guid, name, slug, description, created_at, updated_at FROM categories
WHERE site_id = ? ORDER BY name ASC
`
func (q *Queries) SelectCategoriesOfSite(ctx context.Context, siteID int64) ([]Category, error) {
rows, err := q.db.QueryContext(ctx, selectCategoriesOfSite, siteID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Category
for rows.Next() {
var i Category
if err := rows.Scan(
&i.ID,
&i.SiteID,
&i.Guid,
&i.Name,
&i.Slug,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const selectCategory = `-- name: SelectCategory :one
SELECT id, site_id, guid, name, slug, description, created_at, updated_at FROM categories WHERE id = ? LIMIT 1
`
func (q *Queries) SelectCategory(ctx context.Context, id int64) (Category, error) {
row := q.db.QueryRowContext(ctx, selectCategory, id)
var i Category
err := row.Scan(
&i.ID,
&i.SiteID,
&i.Guid,
&i.Name,
&i.Slug,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const selectCategoryByGUID = `-- name: SelectCategoryByGUID :one
SELECT id, site_id, guid, name, slug, description, created_at, updated_at FROM categories WHERE guid = ? LIMIT 1
`
func (q *Queries) SelectCategoryByGUID(ctx context.Context, guid string) (Category, error) {
row := q.db.QueryRowContext(ctx, selectCategoryByGUID, guid)
var i Category
err := row.Scan(
&i.ID,
&i.SiteID,
&i.Guid,
&i.Name,
&i.Slug,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const selectCategoryBySlugAndSite = `-- name: SelectCategoryBySlugAndSite :one
SELECT id, site_id, guid, name, slug, description, created_at, updated_at FROM categories WHERE site_id = ? AND slug = ? LIMIT 1
`
type SelectCategoryBySlugAndSiteParams struct {
SiteID int64
Slug string
}
func (q *Queries) SelectCategoryBySlugAndSite(ctx context.Context, arg SelectCategoryBySlugAndSiteParams) (Category, error) {
row := q.db.QueryRowContext(ctx, selectCategoryBySlugAndSite, arg.SiteID, arg.Slug)
var i Category
err := row.Scan(
&i.ID,
&i.SiteID,
&i.Guid,
&i.Name,
&i.Slug,
&i.Description,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const selectPostsOfCategory = `-- name: SelectPostsOfCategory :many
SELECT p.id, p.site_id, p.state, p.guid, p.title, p.body, p.slug, p.created_at, p.updated_at, p.published_at, p.deleted_at FROM posts p
INNER JOIN post_categories pc ON pc.post_id = p.id
WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0
ORDER BY p.published_at DESC
LIMIT ? OFFSET ?
`
type SelectPostsOfCategoryParams struct {
CategoryID int64
Limit int64
Offset int64
}
func (q *Queries) SelectPostsOfCategory(ctx context.Context, arg SelectPostsOfCategoryParams) ([]Post, error) {
rows, err := q.db.QueryContext(ctx, selectPostsOfCategory, arg.CategoryID, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Post
for rows.Next() {
var i Post
if err := rows.Scan(
&i.ID,
&i.SiteID,
&i.State,
&i.Guid,
&i.Title,
&i.Body,
&i.Slug,
&i.CreatedAt,
&i.UpdatedAt,
&i.PublishedAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateCategory = `-- name: UpdateCategory :exec
UPDATE categories SET
name = ?,
slug = ?,
description = ?,
updated_at = ?
WHERE id = ?
`
type UpdateCategoryParams struct {
Name string
Slug string
Description string
UpdatedAt int64
ID int64
}
func (q *Queries) UpdateCategory(ctx context.Context, arg UpdateCategoryParams) error {
_, err := q.db.ExecContext(ctx, updateCategory,
arg.Name,
arg.Slug,
arg.Description,
arg.UpdatedAt,
arg.ID,
)
return err
}

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.28.0
package sqlgen

View file

@ -1,9 +1,20 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.28.0
package sqlgen
type Category struct {
ID int64
SiteID int64
Guid string
Name string
Slug string
Description string
CreatedAt int64
UpdatedAt int64
}
type PendingUpload struct {
ID int64
SiteID int64
@ -29,6 +40,11 @@ type Post struct {
DeletedAt int64
}
type PostCategory struct {
PostID int64
CategoryID int64
}
type PublishTarget struct {
ID int64
SiteID int64

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.28.0
// source: pending_uploads.sql
package sqlgen

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.28.0
// source: posts.sql
package sqlgen

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.28.0
// source: pubtargets.sql
package sqlgen

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.28.0
// source: sites.sql
package sqlgen

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.28.0
// source: uploads.sql
package sqlgen

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.28.0
// source: users.sql
package sqlgen

View file

@ -40,6 +40,17 @@ func (db *Provider) Close() error {
return db.drvr.Close()
}
func (db *Provider) BeginTx(ctx context.Context) (*sql.Tx, error) {
return db.drvr.BeginTx(ctx, nil)
}
func (db *Provider) QueriesWithTx(tx *sql.Tx) *Provider {
return &Provider{
drvr: db.drvr,
queries: db.queries.WithTx(tx),
}
}
func (db *Provider) SoftDeletePost(ctx context.Context, postID int64) error {
return db.queries.SoftDeletePost(ctx, sqlgen.SoftDeletePostParams{
DeletedAt: time.Now().Unix(),

View file

@ -98,6 +98,7 @@ func TestProvider_Sites(t *testing.T) {
t.Run("select site by id", func(t *testing.T) {
site := &models.Site{
OwnerID: user.ID,
GUID: models.NewNanoID(),
Title: "Lookup Blog",
Tagline: "Find me by ID",
}
@ -143,10 +144,11 @@ func TestProvider_Posts(t *testing.T) {
require.NoError(t, p.SaveSite(ctx, site))
t.Run("save and select posts", func(t *testing.T) {
guid := models.NewNanoID()
now := time.Date(2026, 2, 19, 12, 0, 0, 0, time.UTC)
post := &models.Post{
SiteID: site.ID,
GUID: "post-001",
GUID: guid,
Title: "First Post",
Body: "Hello world",
Slug: "/2026/02/19/first-post",
@ -158,12 +160,12 @@ func TestProvider_Posts(t *testing.T) {
require.NoError(t, err)
assert.NotZero(t, post.ID)
posts, err := p.SelectPostsOfSite(ctx, site.ID, false)
posts, err := p.SelectPostsOfSite(ctx, site.ID, false, db.PagingParams{Limit: 10, Offset: 0})
require.NoError(t, err)
require.Len(t, posts, 1)
assert.Equal(t, post.ID, posts[0].ID)
assert.Equal(t, site.ID, posts[0].SiteID)
assert.Equal(t, "post-001", posts[0].GUID)
assert.Equal(t, guid, posts[0].GUID)
assert.Equal(t, "First Post", posts[0].Title)
assert.Equal(t, "Hello world", posts[0].Body)
assert.Equal(t, "/2026/02/19/first-post", posts[0].Slug)
@ -173,8 +175,10 @@ func TestProvider_Posts(t *testing.T) {
t.Run("posts ordered by created_at desc", func(t *testing.T) {
// Create a second site to isolate this test
guid := models.NewNanoID()
site2 := &models.Site{
OwnerID: user.ID,
GUID: models.NewNanoID(),
Title: "Second Blog",
Tagline: "",
}
@ -185,7 +189,7 @@ func TestProvider_Posts(t *testing.T) {
post1 := &models.Post{
SiteID: site2.ID,
GUID: "old-post",
GUID: guid,
Title: "Old Post",
Body: "old",
Slug: "/old",
@ -194,7 +198,7 @@ func TestProvider_Posts(t *testing.T) {
}
post2 := &models.Post{
SiteID: site2.ID,
GUID: "new-post",
GUID: models.NewNanoID(),
Title: "New Post",
Body: "new",
Slug: "/new",
@ -205,7 +209,7 @@ func TestProvider_Posts(t *testing.T) {
require.NoError(t, p.SavePost(ctx, post1))
require.NoError(t, p.SavePost(ctx, post2))
posts, err := p.SelectPostsOfSite(ctx, site2.ID, false)
posts, err := p.SelectPostsOfSite(ctx, site2.ID, false, db.PagingParams{Limit: 10, Offset: 0})
require.NoError(t, err)
require.Len(t, posts, 2)
assert.Equal(t, "New Post", posts[0].Title)
@ -215,12 +219,13 @@ func TestProvider_Posts(t *testing.T) {
t.Run("select posts for site with no posts", func(t *testing.T) {
emptySite := &models.Site{
OwnerID: user.ID,
GUID: models.NewNanoID(),
Title: "Empty Blog",
Tagline: "",
}
require.NoError(t, p.SaveSite(ctx, emptySite))
posts, err := p.SelectPostsOfSite(ctx, emptySite.ID, false)
posts, err := p.SelectPostsOfSite(ctx, emptySite.ID, false, db.PagingParams{})
require.NoError(t, err)
assert.Empty(t, posts)
})
@ -239,6 +244,7 @@ func TestProvider_PublishTargets(t *testing.T) {
site := &models.Site{
OwnerID: user.ID,
GUID: models.NewNanoID(),
Title: "My Blog",
Tagline: "A test blog",
}
@ -272,6 +278,7 @@ func TestProvider_PublishTargets(t *testing.T) {
t.Run("select targets for site with no targets", func(t *testing.T) {
emptySite := &models.Site{
OwnerID: user.ID,
GUID: models.NewNanoID(),
Title: "No Targets",
Tagline: "",
}
@ -283,6 +290,165 @@ func TestProvider_PublishTargets(t *testing.T) {
})
}
func TestProvider_Categories(t *testing.T) {
ctx := context.Background()
p := newTestDB(t)
user := &models.User{Username: "testuser", PasswordHashed: []byte("password")}
require.NoError(t, p.SaveUser(ctx, user))
site := &models.Site{OwnerID: user.ID, Title: "My Blog", Tagline: "test"}
require.NoError(t, p.SaveSite(ctx, site))
t.Run("save and select categories", func(t *testing.T) {
now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)
cat := &models.Category{
SiteID: site.ID,
GUID: "cat-001",
Name: "Go Programming",
Slug: "go-programming",
Description: "Posts about Go",
CreatedAt: now,
UpdatedAt: now,
}
err := p.SaveCategory(ctx, cat)
require.NoError(t, err)
assert.NotZero(t, cat.ID)
cats, err := p.SelectCategoriesOfSite(ctx, site.ID)
require.NoError(t, err)
require.Len(t, cats, 1)
assert.Equal(t, "Go Programming", cats[0].Name)
assert.Equal(t, "go-programming", cats[0].Slug)
assert.Equal(t, "Posts about Go", cats[0].Description)
})
t.Run("update category", func(t *testing.T) {
now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)
cat := &models.Category{
SiteID: site.ID,
GUID: "cat-002",
Name: "Original",
Slug: "original",
CreatedAt: now,
UpdatedAt: now,
}
require.NoError(t, p.SaveCategory(ctx, cat))
cat.Name = "Updated"
cat.Slug = "updated"
cat.UpdatedAt = now.Add(time.Hour)
require.NoError(t, p.SaveCategory(ctx, cat))
got, err := p.SelectCategory(ctx, cat.ID)
require.NoError(t, err)
assert.Equal(t, "Updated", got.Name)
assert.Equal(t, "updated", got.Slug)
})
t.Run("delete category", func(t *testing.T) {
now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)
cat := &models.Category{
SiteID: site.ID,
GUID: "cat-003",
Name: "ToDelete",
Slug: "to-delete",
CreatedAt: now,
UpdatedAt: now,
}
require.NoError(t, p.SaveCategory(ctx, cat))
err := p.DeleteCategory(ctx, cat.ID)
require.NoError(t, err)
_, err = p.SelectCategory(ctx, cat.ID)
assert.Error(t, err)
})
}
func TestProvider_PostCategories(t *testing.T) {
ctx := context.Background()
p := newTestDB(t)
user := &models.User{Username: "testuser", PasswordHashed: []byte("password")}
require.NoError(t, p.SaveUser(ctx, user))
site := &models.Site{OwnerID: user.ID, Title: "My Blog", Tagline: "test"}
require.NoError(t, p.SaveSite(ctx, site))
now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)
post := &models.Post{
SiteID: site.ID,
GUID: "post-pc-001",
Title: "Test Post",
Body: "body",
Slug: "/test",
CreatedAt: now,
}
require.NoError(t, p.SavePost(ctx, post))
cat1 := &models.Category{SiteID: site.ID, GUID: "cat-pc-1", Name: "Alpha", Slug: "alpha", CreatedAt: now, UpdatedAt: now}
cat2 := &models.Category{SiteID: site.ID, GUID: "cat-pc-2", Name: "Beta", Slug: "beta", CreatedAt: now, UpdatedAt: now}
require.NoError(t, p.SaveCategory(ctx, cat1))
require.NoError(t, p.SaveCategory(ctx, cat2))
t.Run("set and get post categories", func(t *testing.T) {
err := p.SetPostCategories(ctx, post.ID, []int64{cat1.ID, cat2.ID})
require.NoError(t, err)
cats, err := p.SelectCategoriesOfPost(ctx, post.ID)
require.NoError(t, err)
require.Len(t, cats, 2)
assert.Equal(t, "Alpha", cats[0].Name)
assert.Equal(t, "Beta", cats[1].Name)
})
t.Run("replace post categories", func(t *testing.T) {
err := p.SetPostCategories(ctx, post.ID, []int64{cat2.ID})
require.NoError(t, err)
cats, err := p.SelectCategoriesOfPost(ctx, post.ID)
require.NoError(t, err)
require.Len(t, cats, 1)
assert.Equal(t, "Beta", cats[0].Name)
})
t.Run("clear post categories", func(t *testing.T) {
err := p.SetPostCategories(ctx, post.ID, []int64{})
require.NoError(t, err)
cats, err := p.SelectCategoriesOfPost(ctx, post.ID)
require.NoError(t, err)
assert.Empty(t, cats)
})
t.Run("count posts of category", func(t *testing.T) {
post.State = models.StatePublished
post.PublishedAt = now
require.NoError(t, p.SavePost(ctx, post))
require.NoError(t, p.SetPostCategories(ctx, post.ID, []int64{cat1.ID}))
count, err := p.CountPostsOfCategory(ctx, cat1.ID)
require.NoError(t, err)
assert.Equal(t, int64(1), count)
count, err = p.CountPostsOfCategory(ctx, cat2.ID)
require.NoError(t, err)
assert.Equal(t, int64(0), count)
})
t.Run("cascade delete category removes associations", func(t *testing.T) {
require.NoError(t, p.SetPostCategories(ctx, post.ID, []int64{cat1.ID, cat2.ID}))
require.NoError(t, p.DeleteCategory(ctx, cat1.ID))
cats, err := p.SelectCategoriesOfPost(ctx, post.ID)
require.NoError(t, err)
require.Len(t, cats, 1)
assert.Equal(t, "Beta", cats[0].Name)
})
}
// Verify that password encoding roundtrips correctly through base64
func TestProvider_UserPasswordEncoding(t *testing.T) {
ctx := context.Background()

View file

@ -6,7 +6,9 @@ import (
"fmt"
"html/template"
"io"
"io/fs"
"iter"
"log"
"os"
"path/filepath"
"strings"
@ -31,11 +33,15 @@ type Builder struct {
func New(site pubmodel.Site, opts Options) (*Builder, error) {
tmpls, err := template.New("").
Funcs(templateFns(site, opts)).
ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain)
ParseFS(opts.TemplatesFS, "*.html")
if err != nil {
return nil, err
}
for _, t := range tmpls.Templates() {
log.Printf("Loaded template %s", t.Name())
}
return &Builder{
site: site,
opts: opts,
@ -62,7 +68,13 @@ func (b *Builder) BuildSite(outDir string) error {
if err != nil {
return err
}
if err := b.writePost(buildCtx, post); err != nil {
rp, err := b.renderPostWithCategories(ctx, post)
if err != nil {
return err
}
if err := b.createAtPath(buildCtx, rp.Path, func(f io.Writer) error {
return b.renderTemplate(f, tmplNamePostSingle, rp)
}); err != nil {
return err
}
}
@ -70,10 +82,7 @@ func (b *Builder) BuildSite(outDir string) error {
})
eg.Go(func() error {
if err := b.renderPostList(buildCtx, b.site.PostIter(ctx)); err != nil {
return err
}
return nil
return b.renderPostListWithCategories(buildCtx, ctx)
})
eg.Go(func() error {
@ -93,43 +102,45 @@ func (b *Builder) BuildSite(outDir string) error {
return nil
})
// 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 {
if err := b.writeUploads(buildCtx, b.site.Uploads); err != nil {
return err
}
return nil
return b.writeUploads(buildCtx, b.site.Uploads)
})
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) renderPostList(ctx buildContext, postIter iter.Seq[models.Maybe[*models.Post]]) error {
// TODO: paging
postCopy := make([]*models.Post, 0)
for mp := range postIter {
func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Context) error {
var posts []postSingleData
for mp := range b.site.PostIter(ctx) {
post, err := mp.Get()
if err != nil {
return err
}
postCopy = append(postCopy, post)
rp, err := b.renderPostWithCategories(ctx, post)
if err != nil {
return err
}
posts = append(posts, rp)
}
pl := postListData{
commonData: commonData{Site: b.site},
}
for _, post := range postCopy {
rp, err := b.renderPost(post)
if err != nil {
return err
}
pl.Posts = append(pl.Posts, rp)
Posts: posts,
}
return b.createAtPath(ctx, "", func(f io.Writer) error {
return b.createAtPath(bctx, "", func(f io.Writer) error {
return b.renderTemplate(f, tmplNamePostList, pl)
})
}
@ -156,6 +167,18 @@ func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[*
return err
}
var catName string
if b.site.CategoriesOfPost != nil {
cats, err := b.site.CategoriesOfPost(context.Background(), post.ID)
if err == nil && len(cats) > 0 {
names := make([]string, len(cats))
for i, c := range cats {
names[i] = c.Name
}
catName = strings.Join(names, ", ")
}
}
postTitle := post.Title
if postTitle != "" {
postTitle = opts.titlePrefix + postTitle
@ -166,6 +189,8 @@ func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[*
Title: postTitle,
Link: &feedhub.Link{Href: renderedPost.PostURL},
Content: string(renderedPost.HTML),
// TO FIX: Why the heck does this only include the first category?
Category: catName,
// TO FIX: Created should be first published
Created: post.PublishedAt,
Updated: post.UpdatedAt,
@ -243,14 +268,142 @@ func (b *Builder) renderPost(post *models.Post) (postSingleData, error) {
}, nil
}
func (b *Builder) writePost(ctx buildContext, post *models.Post) error {
// renderPostWithCategories renders a post and attaches its categories.
func (b *Builder) renderPostWithCategories(ctx context.Context, post *models.Post) (postSingleData, error) {
rp, err := b.renderPost(post)
if err != nil {
return postSingleData{}, err
}
if b.site.CategoriesOfPost != nil {
cats, err := b.site.CategoriesOfPost(ctx, post.ID)
if err != nil {
return postSingleData{}, err
}
rp.Categories = cats
}
return rp, nil
}
func (b *Builder) renderCategoryList(ctx buildContext) error {
var items []categoryListItem
for _, cwc := range b.site.Categories {
if cwc.PostCount == 0 {
continue
}
items = append(items, categoryListItem{
CategoryWithCount: cwc,
Path: fmt.Sprintf("/categories/%s", cwc.Slug),
})
}
if len(items) == 0 {
return nil
}
data := categoryListData{
commonData: commonData{Site: b.site},
Categories: items,
}
return b.createAtPath(ctx, "/categories", func(f io.Writer) error {
return b.renderTemplate(f, tmplNameCategoryList, data)
})
}
func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) error {
for _, cwc := range b.site.Categories {
if cwc.PostCount == 0 {
continue
}
var posts []postSingleData
for mp := range b.site.PostIterByCategory(goCtx, cwc.ID) {
post, err := mp.Get()
if err != nil {
return err
}
rp, err := b.renderPostWithCategories(goCtx, post)
if err != nil {
return err
}
posts = append(posts, rp)
}
return b.createAtPath(ctx, rp.Path, func(f io.Writer) error {
return b.renderTemplate(f, tmplNamePostSingle, 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
})
}
@ -288,7 +441,7 @@ func (b *Builder) renderTemplate(w io.Writer, name string, data interface{}) err
func (b *Builder) writeUploads(ctx buildContext, uploads []models.Upload) error {
for _, u := range uploads {
fullPath := filepath.Join(ctx.outDir, "uploads", u.Slug)
fullPath := filepath.Join(ctx.outDir, b.opts.BaseUploads, u.Slug)
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
return err
}
@ -316,3 +469,37 @@ func (b *Builder) writeUploads(ctx buildContext, uploads []models.Upload) error
}
return nil
}
func (b *Builder) writeStaticAssets(ctx buildContext) error {
return fs.WalkDir(b.opts.StaticFS, ".", func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
} else if d.IsDir() {
return nil
}
fullPath := filepath.Join(ctx.outDir, b.opts.BaseStatic, path)
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
return err
}
return func() error {
r, err := b.opts.StaticFS.Open(path)
if err != nil {
return err
}
defer r.Close()
w, err := os.Create(fullPath)
if err != nil {
return err
}
defer w.Close()
if _, err := io.Copy(w, r); err != nil {
return err
}
return nil
}()
})
}

View file

@ -1,6 +1,8 @@
package sitebuilder_test
import (
"context"
"iter"
"os"
"path/filepath"
"testing"
@ -18,11 +20,11 @@ func TestBuilder_BuildSite(t *testing.T) {
"posts_single.html": {Data: []byte(`{{ .HTML }}`)},
"posts_list.html": {Data: []byte(`{{ range .Posts}}<a href="{{url_abs .Path}}">{{.Post.Title}}</a>,{{ end }}`)},
"layout_main.html": {Data: []byte(`{{ .Body }}`)},
"categories_list.html": {Data: []byte(`{{ range .Categories}}<a href="{{url_abs .Path}}">{{.Name}}</a>,{{ end }}`)},
"categories_single.html": {Data: []byte(`<h2>{{.Category.Name}}</h2>`)},
}
site := pubmodel.Site{
BaseURL: "https://example.com",
Posts: []*models.Post{
posts := []*models.Post{
{
Title: "Test Post",
Slug: "/2026/02/18/test-post",
@ -33,6 +35,18 @@ func TestBuilder_BuildSite(t *testing.T) {
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{
@ -58,5 +72,4 @@ func TestBuilder_BuildSite(t *testing.T) {
assert.Equal(t, content, string(fileContent))
}
})
}

View file

@ -20,15 +20,26 @@ const (
// tmplNameLayoutMain is the template for the main layout (layoutMainData)
tmplNameLayoutMain = "layout_main.html"
// tmplNameCategoryList is the template for the category index page
tmplNameCategoryList = "categories_list.html"
// tmplNameCategorySingle is the template for a single category page
tmplNameCategorySingle = "categories_single.html"
)
type Options struct {
// BasePosts is the base path for posts.
BasePosts string
BasePosts string // BasePosts is the base path for posts.
BaseUploads string // BaseUploads is the base path for uploads.
BaseStatic string // BaseStatic is the base path for static assets.
// TemplatesFS provides the raw templates for rendering the site.
TemplatesFS fs.FS
// StaticFS provides the raw assets for the site. This will be written as is
// from the BaseStatic dir.
StaticFS fs.FS
// FeedItems holds the number of posts to show in the feed.
FeedItems int
@ -45,6 +56,7 @@ type postSingleData struct {
HTML template.HTML
Path string
PostURL string
Categories []*models.Category
}
type postListData struct {
@ -56,3 +68,21 @@ type layoutData struct {
commonData
Body template.HTML
}
type categoryListData struct {
commonData
Categories []categoryListItem
}
type categoryListItem struct {
models.CategoryWithCount
Path string
}
type categorySingleData struct {
commonData
Category *models.Category
DescriptionHTML template.HTML
Posts []postSingleData
Path string
}

View file

@ -1,94 +0,0 @@
package sitereader
import (
"bytes"
"io"
"io/fs"
"time"
"gopkg.in/yaml.v3"
"lmika.dev/lmika/weiro/models"
)
type Provider struct {
fs fs.FS
}
func New(fs fs.FS) *Provider {
return &Provider{
fs: fs,
}
}
func (p *Provider) ReadSite() (ReadSiteModels, error) {
posts, err := p.ListPosts()
if err != nil {
return ReadSiteModels{}, err
}
meta := siteMeta{}
metaBytes, err := fs.ReadFile(p.fs, "site.yaml")
if err != nil {
return ReadSiteModels{}, err
}
if err := yaml.Unmarshal(metaBytes, &meta); err != nil {
return ReadSiteModels{}, err
}
site := models.Site{
Title: meta.Title,
Tagline: meta.Tagline,
}
return ReadSiteModels{
Site: site,
Posts: posts,
}, nil
}
func (p *Provider) ListPosts() (posts []*models.Post, err error) {
err = fs.WalkDir(p.fs, "posts", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
} else if d.IsDir() {
return nil
}
post, err := p.ReadPost(path)
if err != nil {
return err
}
posts = append(posts, post)
return nil
})
return posts, err
}
func (p *Provider) ReadPost(path string) (*models.Post, error) {
data, err := fs.ReadFile(p.fs, path)
if err != nil {
return nil, err
}
// Split front matter and content
parts := bytes.SplitN(data, []byte("---"), 3)
if len(parts) < 3 {
return nil, io.ErrUnexpectedEOF
}
var meta postMeta
if err := yaml.Unmarshal(parts[1], &meta); err != nil {
return nil, err
}
post := models.Post{
Slug: meta.Slug,
Title: meta.Title,
GUID: meta.ID,
PublishedAt: meta.Date,
CreatedAt: time.Now(),
}
post.Body = string(bytes.TrimPrefix(parts[2], []byte("\n")))
return &post, nil
}

View file

@ -1,106 +0,0 @@
package sitereader_test
import (
"testing"
"testing/fstest"
"time"
"github.com/stretchr/testify/assert"
"lmika.dev/lmika/weiro/providers/sitereader"
)
func TestProvider_ReadPost(t *testing.T) {
t.Run("with meta", func(t *testing.T) {
testFS := fstest.MapFS{
"site.yaml": {Data: []byte(`base_url: https://example.com`)},
"posts/test.md": {Data: []byte(`---
date: 2026-02-18T19:59:00Z
title: Test Post Here
tags: [test, example]
---
This is just a test post.
`)},
}
pr := sitereader.New(testFS)
post, err := pr.ReadPost("posts/test.md")
assert.NoError(t, err)
assert.Equal(t, "Test Post Here", post.Title)
assert.Equal(t, time.Date(2026, 2, 18, 19, 59, 0, 0, time.UTC), post.PublishedAt)
assert.Equal(t, "This is just a test post.\n", post.Body)
})
t.Run("without meta", func(t *testing.T) {
testFS := fstest.MapFS{
"posts/test.md": {Data: []byte(`---
---
This is just a test post.
`)},
}
pr := sitereader.New(testFS)
post, err := pr.ReadPost("posts/test.md")
assert.NoError(t, err)
assert.Equal(t, "", post.Title)
assert.Equal(t, "This is just a test post.\n", post.Body)
})
}
func TestProvider_ListPosts(t *testing.T) {
testFS := fstest.MapFS{
"posts/01-post1.md": {Data: []byte(`---
id: 111
date: 2026-02-18T19:59:00Z
title: Test Post Here
tags: [test, example]
---
This is just a test post.
`)},
"posts/02-post2.md": {Data: []byte(`---
id: 222
---
This is just a test post.
`)},
}
pr := sitereader.New(testFS)
posts, err := pr.ListPosts()
assert.NoError(t, err)
assert.Equal(t, 2, len(posts))
assert.Equal(t, "111", posts[0].GUID)
assert.Equal(t, "222", posts[1].GUID)
}
func TestProvider_ReadSite(t *testing.T) {
testFS := fstest.MapFS{
"site.yaml": {Data: []byte(`base_url: https://example.com`)},
"posts/01-post1.md": {Data: []byte(`---
id: 111
date: 2026-02-18T19:59:00Z
title: Test Post Here
tags: [test, example]
---
This is just a test post.
`)},
"posts/02-post2.md": {Data: []byte(`---
id: 222
---
This is just a test post.
`)},
}
pr := sitereader.New(testFS)
sites, err := pr.ReadSite()
assert.NoError(t, err)
assert.Equal(t, 2, len(sites.Posts))
assert.Equal(t, "111", sites.Posts[0].GUID)
assert.Equal(t, "222", sites.Posts[1].GUID)
}

View file

@ -0,0 +1,178 @@
package categories
import (
"context"
"time"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db"
"lmika.dev/lmika/weiro/services/publisher"
)
type CreateCategoryParams struct {
GUID string `form:"guid" json:"guid"`
Name string `form:"name" json:"name"`
Slug string `form:"slug" json:"slug"`
Description string `form:"description" json:"description"`
}
type Service struct {
db *db.Provider
publisher *publisher.Queue
}
func New(db *db.Provider, publisher *publisher.Queue) *Service {
return &Service{db: db, publisher: publisher}
}
func (s *Service) ListCategories(ctx context.Context) ([]*models.Category, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
}
return s.db.SelectCategoriesOfSite(ctx, site.ID)
}
// ListCategoriesWithCounts returns all categories for the site with published post counts.
func (s *Service) ListCategoriesWithCounts(ctx context.Context) ([]models.CategoryWithCount, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
}
cats, err := s.db.SelectCategoriesOfSite(ctx, site.ID)
if err != nil {
return nil, err
}
result := make([]models.CategoryWithCount, len(cats))
for i, cat := range cats {
count, err := s.db.CountPostsOfCategory(ctx, cat.ID)
if err != nil {
return nil, err
}
result[i] = models.CategoryWithCount{
Category: *cat,
PostCount: int(count),
DescriptionBrief: models.BriefDescription(cat.Description),
}
}
return result, nil
}
func (s *Service) GetCategory(ctx context.Context, id int64) (*models.Category, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
}
cat, err := s.db.SelectCategory(ctx, id)
if err != nil {
return nil, err
}
if cat.SiteID != site.ID {
return nil, models.NotFoundError
}
return cat, nil
}
func (s *Service) CreateCategory(ctx context.Context, params CreateCategoryParams) (*models.Category, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
}
now := time.Now()
slug := params.Slug
if slug == "" {
slug = models.GenerateCategorySlug(params.Name)
}
// Check for slug collision
if _, err := s.db.SelectCategoryBySlugAndSite(ctx, site.ID, slug); err == nil {
return nil, models.SlugConflictError
} else if !db.ErrorIsNoRows(err) {
return nil, err
}
cat := &models.Category{
SiteID: site.ID,
GUID: params.GUID,
Name: params.Name,
Slug: slug,
Description: params.Description,
CreatedAt: now,
UpdatedAt: now,
}
if cat.GUID == "" {
cat.GUID = models.NewNanoID()
}
if err := s.db.SaveCategory(ctx, cat); err != nil {
return nil, err
}
s.publisher.Queue(site)
return cat, nil
}
func (s *Service) UpdateCategory(ctx context.Context, id int64, params CreateCategoryParams) (*models.Category, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
}
cat, err := s.db.SelectCategory(ctx, id)
if err != nil {
return nil, err
}
if cat.SiteID != site.ID {
return nil, models.NotFoundError
}
slug := params.Slug
if slug == "" {
slug = models.GenerateCategorySlug(params.Name)
}
// Check slug collision (exclude self)
if existing, err := s.db.SelectCategoryBySlugAndSite(ctx, site.ID, slug); err == nil && existing.ID != cat.ID {
return nil, models.SlugConflictError
} else if err != nil && !db.ErrorIsNoRows(err) {
return nil, err
}
cat.Name = params.Name
cat.Slug = slug
cat.Description = params.Description
cat.UpdatedAt = time.Now()
if err := s.db.SaveCategory(ctx, cat); err != nil {
return nil, err
}
s.publisher.Queue(site)
return cat, nil
}
func (s *Service) DeleteCategory(ctx context.Context, id int64) error {
site, ok := models.GetSite(ctx)
if !ok {
return models.SiteRequiredError
}
cat, err := s.db.SelectCategory(ctx, id)
if err != nil {
return err
}
if cat.SiteID != site.ID {
return models.NotFoundError
}
if err := s.db.DeleteCategory(ctx, id); err != nil {
return err
}
s.publisher.Queue(site)
return nil
}

View file

@ -1,54 +0,0 @@
package _import
import (
"context"
"os"
"emperror.dev/errors"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db"
"lmika.dev/lmika/weiro/providers/sitereader"
)
type Service struct {
db *db.Provider
}
func New(db *db.Provider) *Service {
return &Service{
db: db,
}
}
func (s *Service) Import(ctx context.Context, sitePath string) (models.Site, error) {
user, ok := models.GetUser(ctx)
if !ok {
return models.Site{}, models.UserRequiredError
}
sr := sitereader.New(os.DirFS(sitePath))
readSite, err := sr.ReadSite()
if err != nil {
return models.Site{}, errors.Wrap(err, "failed to read site")
}
site := readSite.Site
site.OwnerID = user.ID
if err := s.db.SaveSite(ctx, &site); err != nil {
return models.Site{}, errors.Wrap(err, "failed to save site")
}
for _, post := range readSite.Posts {
post.SiteID = site.ID
if post.GUID == "" {
post.GUID = models.NewNanoID()
}
if err := s.db.SavePost(ctx, post); err != nil {
return models.Site{}, errors.Wrap(err, "failed to save post")
}
}
return site, nil
}

View file

@ -14,6 +14,7 @@ type CreatePostParams struct {
Title string `form:"title" json:"title"`
Body string `form:"body" json:"body"`
Action string `form:"action" json:"action"`
CategoryIDs []int64 `form:"category_ids" json:"category_ids"`
}
func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*models.Post, error) {
@ -53,7 +54,21 @@ func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*mod
// Leave unchanged
}
if err := s.db.SavePost(ctx, post); err != nil {
// Use a transaction for atomicity of post save + category reassignment
tx, err := s.db.BeginTx(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback()
txDB := s.db.QueriesWithTx(tx)
if err := txDB.SavePost(ctx, post); err != nil {
return nil, err
}
if err := txDB.SetPostCategories(ctx, post.ID, params.CategoryIDs); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}

View file

@ -7,7 +7,12 @@ import (
"lmika.dev/lmika/weiro/providers/db"
)
func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*models.Post, error) {
type PostWithCategories struct {
*models.Post
Categories []*models.Category
}
func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*PostWithCategories, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
@ -21,7 +26,15 @@ func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*models.Po
return nil, err
}
return posts, nil
result := make([]*PostWithCategories, len(posts))
for i, post := range posts {
cats, err := s.db.SelectCategoriesOfPost(ctx, post.ID)
if err != nil {
return nil, err
}
result[i] = &PostWithCategories{Post: post, Categories: cats}
}
return result, nil
}
func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error) {
@ -32,3 +45,7 @@ func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error)
return post, nil
}
func (s *Service) GetPostCategories(ctx context.Context, postID int64) ([]*models.Category, error) {
return s.db.SelectCategoriesOfPost(ctx, postID)
}

View file

@ -8,7 +8,7 @@ import (
"lmika.dev/lmika/weiro/providers/db"
)
// PostIter returns a post iterator which returns posts in reverse chronological order.
// postIter returns a post iterator which returns posts in reverse chronological order.
func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] {
return func(yield func(models.Maybe[*models.Post]) bool) {
paging := db.PagingParams{Offset: 0, Limit: 50}
@ -39,3 +39,26 @@ func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Ma
}
}
}
// postIterByCategory returns a post iterator for posts in a specific category.
func (s *Publisher) postIterByCategory(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]] {
return func(yield func(models.Maybe[*models.Post]) bool) {
paging := db.PagingParams{Offset: 0, Limit: 50}
for {
page, err := s.db.SelectPostsOfCategory(ctx, categoryID, paging)
if err != nil {
yield(models.Maybe[*models.Post]{Err: err})
return
}
if len(page) == 0 {
return
}
for _, post := range page {
if !yield(models.Maybe[*models.Post]{Value: post}) {
return
}
}
paging.Offset += paging.Limit
}
}
}

View file

@ -3,6 +3,7 @@ package publisher
import (
"context"
"io"
"io/fs"
"iter"
"log"
"os"
@ -46,6 +47,24 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error {
return err
}
// Fetch categories with counts
cats, err := p.db.SelectCategoriesOfSite(ctx, site.ID)
if err != nil {
return err
}
var catsWithCounts []models.CategoryWithCount
for _, cat := range cats {
count, err := p.db.CountPostsOfCategory(ctx, cat.ID)
if err != nil {
return err
}
catsWithCounts = append(catsWithCounts, models.CategoryWithCount{
Category: *cat,
PostCount: int(count),
DescriptionBrief: models.BriefDescription(cat.Description),
})
}
for _, target := range targets {
if !target.Enabled {
continue
@ -58,6 +77,13 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error {
},
BaseURL: target.BaseURL,
Uploads: uploads,
Categories: catsWithCounts,
PostIterByCategory: func(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]] {
return p.postIterByCategory(ctx, categoryID)
},
CategoriesOfPost: func(ctx context.Context, postID int64) ([]*models.Category, error) {
return p.db.SelectCategoriesOfPost(ctx, postID)
},
OpenUpload: func(u models.Upload) (io.ReadCloser, error) {
return p.up.OpenUpload(site, u)
},
@ -77,9 +103,22 @@ func (p *Publisher) publishSite(ctx context.Context, pubSite pubmodel.Site, targ
renderTZ = time.UTC
}
templateFS, err := fs.Sub(simplecss.FS, "templates")
if err != nil {
return err
}
staticFS, err := fs.Sub(simplecss.FS, "static")
if err != nil {
return err
}
sb, err := sitebuilder.New(pubSite, sitebuilder.Options{
BasePosts: "/posts",
TemplatesFS: simplecss.FS,
BaseUploads: "/uploads",
BaseStatic: "/static",
TemplatesFS: templateFS,
StaticFS: staticFS,
FeedItems: 30,
RenderTZ: renderTZ,
})

View file

@ -7,6 +7,7 @@ import (
"lmika.dev/lmika/weiro/providers/db"
"lmika.dev/lmika/weiro/providers/uploadfiles"
"lmika.dev/lmika/weiro/services/auth"
"lmika.dev/lmika/weiro/services/categories"
"lmika.dev/lmika/weiro/services/posts"
"lmika.dev/lmika/weiro/services/publisher"
"lmika.dev/lmika/weiro/services/sites"
@ -21,6 +22,7 @@ type Services struct {
Posts *posts.Service
Sites *sites.Service
Uploads *uploads.Service
Categories *categories.Service
}
func New(cfg config.Config) (*Services, error) {
@ -37,6 +39,7 @@ func New(cfg config.Config) (*Services, error) {
postService := posts.New(dbp, publisherQueue)
siteService := sites.New(dbp)
uploadService := uploads.New(dbp, ufp, filepath.Join(cfg.ScratchDir, "uploads", "pending"))
categoriesService := categories.New(dbp, publisherQueue)
return &Services{
DB: dbp,
@ -46,6 +49,7 @@ func New(cfg config.Config) (*Services, error) {
Posts: postService,
Sites: siteService,
Uploads: uploadService,
Categories: categoriesService,
}, nil
}

View file

@ -0,0 +1,53 @@
-- name: SelectCategoriesOfSite :many
SELECT * FROM categories
WHERE site_id = ? ORDER BY name ASC;
-- name: SelectCategory :one
SELECT * FROM categories WHERE id = ? LIMIT 1;
-- name: SelectCategoryByGUID :one
SELECT * FROM categories WHERE guid = ? LIMIT 1;
-- name: SelectCategoryBySlugAndSite :one
SELECT * FROM categories WHERE site_id = ? AND slug = ? LIMIT 1;
-- name: SelectCategoriesOfPost :many
SELECT c.* FROM categories c
INNER JOIN post_categories pc ON pc.category_id = c.id
WHERE pc.post_id = ?
ORDER BY c.name ASC;
-- name: SelectPostsOfCategory :many
SELECT p.* FROM posts p
INNER JOIN post_categories pc ON pc.post_id = p.id
WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0
ORDER BY p.published_at DESC
LIMIT ? OFFSET ?;
-- name: CountPostsOfCategory :one
SELECT COUNT(*) FROM posts p
INNER JOIN post_categories pc ON pc.post_id = p.id
WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0;
-- name: InsertCategory :one
INSERT INTO categories (
site_id, guid, name, slug, description, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id;
-- name: UpdateCategory :exec
UPDATE categories SET
name = ?,
slug = ?,
description = ?,
updated_at = ?
WHERE id = ?;
-- name: DeleteCategory :exec
DELETE FROM categories WHERE id = ?;
-- name: InsertPostCategory :exec
INSERT OR IGNORE INTO post_categories (post_id, category_id) VALUES (?, ?);
-- name: DeletePostCategoriesByPost :exec
DELETE FROM post_categories WHERE post_id = ?;

View file

@ -0,0 +1,23 @@
CREATE TABLE categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL,
guid TEXT NOT NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE
);
CREATE INDEX idx_categories_site ON categories (site_id);
CREATE UNIQUE INDEX idx_categories_guid ON categories (guid);
CREATE UNIQUE INDEX idx_categories_site_slug ON categories (site_id, slug);
CREATE TABLE post_categories (
post_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
PRIMARY KEY (post_id, category_id),
FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE
);
CREATE INDEX idx_post_categories_category ON post_categories (category_id);

View file

@ -10,6 +10,9 @@
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/posts">Posts</a>
</li>
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/categories">Categories</a>
</li>
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/uploads">Uploads</a>
</li>

View file

@ -0,0 +1,47 @@
<main class="container">
<div class="my-4">
<h5>{{ if .isNew }}New Category{{ else }}Edit Category{{ end }}</h5>
</div>
{{ if .isNew }}
<form method="post" action="/sites/{{ .site.ID }}/categories">
{{ else }}
<form method="post" action="/sites/{{ .site.ID }}/categories/{{ .category.ID }}">
{{ end }}
<input type="hidden" name="guid" value="{{ .category.GUID }}">
<div class="row mb-3">
<label for="catName" class="col-sm-3 col-form-label text-end">Name</label>
<div class="col-sm-6">
<input type="text" class="form-control" id="catName" name="name" value="{{ .category.Name }}">
</div>
</div>
<div class="row mb-3">
<label for="catSlug" class="col-sm-3 col-form-label text-end">Slug</label>
<div class="col-sm-6">
<input type="text" class="form-control" id="catSlug" name="slug" value="{{ .category.Slug }}">
<div class="form-text">Auto-generated from name if left blank.</div>
</div>
</div>
<div class="row mb-3">
<label for="catDesc" class="col-sm-3 col-form-label text-end">Description</label>
<div class="col-sm-9">
<textarea class="form-control" id="catDesc" name="description" rows="5">{{ .category.Description }}</textarea>
<div class="form-text">Markdown supported. Displayed on the category archive page.</div>
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3"></div>
<div class="col-sm-9">
<button type="submit" class="btn btn-primary">{{ if .isNew }}Create{{ else }}Save{{ end }}</button>
{{ if not .isNew }}
<button type="button" class="btn btn-outline-danger ms-2"
onclick="if(confirm('Delete this category? Posts will not be deleted.')) { document.getElementById('delete-form').submit(); }">Delete</button>
{{ end }}
</div>
</div>
</form>
{{ if not .isNew }}
<form id="delete-form" method="post" action="/sites/{{ .site.ID }}/categories/{{ .category.ID }}/delete" style="display:none;"></form>
{{ end }}
</main>

View file

@ -0,0 +1,32 @@
<main class="container">
<div class="my-4 d-flex justify-content-between align-items-baseline">
<div>
<a href="/sites/{{ .site.ID }}/categories/new" class="btn btn-success">New Category</a>
</div>
</div>
{{ if .categories }}
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Posts</th>
</tr>
</thead>
<tbody>
{{ range .categories }}
<tr>
<td><a href="/sites/{{ $.site.ID }}/categories/{{ .ID }}">{{ .Name }}</a></td>
<td><code>{{ .Slug }}</code></td>
<td>{{ .PostCount }}</td>
</tr>
{{ end }}
</tbody>
</table>
{{ else }}
<div class="h4 m-3 text-center">
<div class="position-absolute top-50 start-50 translate-middle">📚<br>No categories yet.</div>
</div>
{{ end }}
</main>

View file

@ -7,7 +7,7 @@
<link rel="stylesheet" href="/static/assets/main.css">
<script src="/static/assets/main.js" type="module"></script>
</head>
<body class="min-vh-100 d-flex flex-column">
<body class="d-flex flex-column {{.bodyClass}}">
{{ template "_common/nav" . }}
{{ embed }}

View file

@ -1,16 +1,16 @@
{{ $isPublished := ne .post.State 1 }}
<main class="flex-grow-1 position-relative">
<form action="/sites/{{.site.ID}}/posts" method="post" class="container-fluid post-form p-2"
<form action="/sites/{{.site.ID}}/posts" method="post" class="container-fluid post-form py-2"
data-controller="postedit"
data-action="keydown.meta+s->postedit#save keydown.meta+enter->postedit#publish"
data-postedit-save-action-value="{{ if $isPublished }}Update{{ else }}Save Draft{{ end }}">
<div class="row">
<div class="col-md-9">
<input type="hidden" name="guid" value="{{ .post.GUID }}">
<div class="mb-2">
<input type="text" name="title" class="form-control" placeholder="Title" value="{{ .post.Title }}">
</div>
<div>
<textarea data-postedit-target="bodyTextEdit" name="body" class="form-control" rows="3">{{.post.Body}}</textarea>
</div>
<textarea data-postedit-target="bodyTextEdit" name="body" class="form-control flex-grow-1" rows="3">{{.post.Body}}</textarea>
<div>
{{ if $isPublished }}
<input type="submit" name="action" class="btn btn-primary mt-2" value="Update">
@ -19,5 +19,24 @@
<input type="submit" name="action" class="btn btn-secondary mt-2" value="Save Draft">
{{ end }}
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-header">Categories</div>
<div class="card-body">
{{ range .categories }}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="category_ids"
value="{{ .ID }}" id="cat-{{ .ID }}"
{{ if index $.selectedCategories .ID }}checked{{ end }}>
<label class="form-check-label" for="cat-{{ .ID }}">{{ .Name }}</label>
</div>
{{ else }}
<span class="text-muted">No categories yet.</span>
{{ end }}
</div>
</div>
</div>
</div>
</form>
</main>

View file

@ -26,11 +26,11 @@
{{ if $p.Title }}<h4 class="mb-3">{{ $p.Title }}</h4>{{ end }}
{{ markdown $p.Body $.site }}
<div class="mb-3 d-flex align-items-center">
{{ if eq .State 1 }}
<span class="text-muted">{{ $.user.FormatTime .UpdatedAt }}</span> <span class="ms-3 badge text-primary-emphasis bg-primary-subtle border border-primary-subtle">Draft</span>
<div class="mb-3 d-flex align-items-center flex-wrap gap-1">
{{ if eq $p.State 1 }}
<span class="text-muted post-date">{{ $.user.FormatTime $p.UpdatedAt }}</span> <span class="ms-3 badge text-primary-emphasis bg-primary-subtle border border-primary-subtle">Draft</span>
{{ else }}
<span class="text-muted">{{ $.user.FormatTime .PublishedAt }}</span>
<span class="text-muted post-date">{{ $.user.FormatTime $p.PublishedAt }}</span>
{{ end }}
</div>