23 KiB
Paging Feature 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 offset-based pagination to the admin post list and the generated static site (posts and category listings).
Architecture: Add a posts_per_page column to the sites table for configurable page size on the generated site. Admin uses a hardcoded page size of 25. The existing db.PagingParams and LIMIT/OFFSET SQL infrastructure is reused. A shared models.PageInfo type carries pagination state to templates.
Tech Stack: Go, SQLite, sqlc, Fiber v3, html/template, Bootstrap
Task 1: Add posts_per_page column and regenerate sqlc
Files:
-
Create:
sql/schema/05_posts_per_page.up.sql -
Modify:
sql/queries/sites.sql:10-19(InsertSite query) -
Modify:
sql/queries/sites.sql:24-25(UpdateSite query) -
Regenerate:
providers/db/gen/sqlgen/(sqlc output) -
Step 1: Create migration file
Create sql/schema/05_posts_per_page.up.sql:
ALTER TABLE sites ADD COLUMN posts_per_page INTEGER NOT NULL DEFAULT 10;
- Step 2: Update the InsertSite SQL query
In sql/queries/sites.sql, update the InsertSite query (lines 10-19) to include posts_per_page:
-- name: InsertSite :one
INSERT INTO sites (
owner_id,
guid,
title,
tagline,
timezone,
posts_per_page,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id;
- Step 3: Update the UpdateSite SQL query
In sql/queries/sites.sql, update line 24-25:
-- name: UpdateSite :exec
UPDATE sites SET title = ?, tagline = ?, timezone = ?, posts_per_page = ? WHERE id = ?;
- Step 4: Regenerate sqlc
Run: sqlc generate
Expected: providers/db/gen/sqlgen/ files updated with new PostsPerPage field on Site struct, updated InsertSiteParams and UpdateSiteParams.
- Step 5: Run tests to verify nothing broke
Run: go test ./...
Expected: All existing tests pass.
- Step 6: Commit
git add sql/schema/05_posts_per_page.up.sql sql/queries/sites.sql providers/db/gen/sqlgen/
git commit -m "feat: add posts_per_page column to sites table"
Task 2: Update Site model and DB provider for PostsPerPage
Files:
-
Modify:
models/sites.go:24-33(Site struct) -
Modify:
providers/db/sites.go:42-65(SaveSite) -
Modify:
providers/db/sites.go:102-112(dbSiteToSite) -
Step 1: Add
PostsPerPagetomodels.Site
In models/sites.go, add to the Site struct (after Timezone):
PostsPerPage int
- Step 2: Update
dbSiteToSiteinproviders/db/sites.go
In providers/db/sites.go, update dbSiteToSite (line 102) to map the new field:
func dbSiteToSite(row sqlgen.Site) models.Site {
return models.Site{
ID: row.ID,
OwnerID: row.OwnerID,
GUID: row.Guid,
Title: row.Title,
Timezone: row.Timezone,
Tagline: row.Tagline,
PostsPerPage: int(row.PostsPerPage),
Created: time.Unix(row.CreatedAt, 0).UTC(),
}
}
- Step 3: Update
SaveSiteto includePostsPerPage
In providers/db/sites.go, update the InsertSite call (line 44) to include PostsPerPage:
newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{
OwnerID: site.OwnerID,
Guid: site.GUID,
Title: site.Title,
Tagline: site.Tagline,
Timezone: site.Timezone,
PostsPerPage: int64(site.PostsPerPage),
CreatedAt: timeToInt(site.Created),
})
Update the UpdateSite call (line 59) to include PostsPerPage:
return db.queries.UpdateSite(ctx, sqlgen.UpdateSiteParams{
Title: site.Title,
Tagline: site.Tagline,
Timezone: site.Timezone,
PostsPerPage: int64(site.PostsPerPage),
ID: site.ID,
})
- Step 4: Run tests
Run: go test ./...
Expected: All tests pass.
- Step 5: Commit
git add models/sites.go providers/db/sites.go sql/queries/sites.sql providers/db/gen/sqlgen/
git commit -m "feat: add PostsPerPage to Site model and DB provider"
Task 3: Add CountPostsOfSite SQL query and DB method
Files:
-
Modify:
sql/queries/posts.sql(add count query) -
Modify:
providers/db/posts.go(add CountPostsOfSite method) -
Modify:
providers/db/provider_test.go(add test) -
Regenerate:
providers/db/gen/sqlgen/ -
Step 1: Write the failing test
Add to providers/db/provider_test.go inside TestProvider_Posts:
t.Run("count posts of site", func(t *testing.T) {
countSite := &models.Site{
OwnerID: user.ID,
GUID: models.NewNanoID(),
Title: "Count Blog",
}
require.NoError(t, p.SaveSite(ctx, countSite))
now := time.Date(2026, 3, 22, 12, 0, 0, 0, time.UTC)
for i := 0; i < 3; i++ {
post := &models.Post{
SiteID: countSite.ID,
GUID: models.NewNanoID(),
Title: fmt.Sprintf("Post %d", i),
Body: "body",
Slug: fmt.Sprintf("/post-%d", i),
CreatedAt: now,
}
require.NoError(t, p.SavePost(ctx, post))
}
count, err := p.CountPostsOfSite(ctx, countSite.ID, false)
require.NoError(t, err)
assert.Equal(t, int64(3), count)
// Soft-delete one post
posts, err := p.SelectPostsOfSite(ctx, countSite.ID, false, db.PagingParams{Limit: 10, Offset: 0})
require.NoError(t, err)
require.NoError(t, p.SoftDeletePost(ctx, posts[0].ID))
count, err = p.CountPostsOfSite(ctx, countSite.ID, false)
require.NoError(t, err)
assert.Equal(t, int64(2), count)
count, err = p.CountPostsOfSite(ctx, countSite.ID, true)
require.NoError(t, err)
assert.Equal(t, int64(1), count)
})
- Step 2: Run test to verify it fails
Run: go test ./providers/db/ -run TestProvider_Posts/count_posts_of_site -v
Expected: FAIL — CountPostsOfSite method does not exist.
- Step 3: Add SQL query
Add to sql/queries/posts.sql:
-- name: CountPostsOfSite :one
SELECT COUNT(*) FROM posts
WHERE site_id = sqlc.arg(site_id) AND (
CASE CAST (sqlc.arg(post_filter) AS TEXT)
WHEN 'deleted' THEN deleted_at > 0
ELSE deleted_at = 0
END
);
Run: sqlc generate
- Step 4: Add DB provider method
Add to providers/db/posts.go:
func (db *Provider) CountPostsOfSite(ctx context.Context, siteID int64, showDeleted bool) (int64, error) {
filter := "active"
if showDeleted {
filter = "deleted"
}
return db.queries.CountPostsOfSite(ctx, sqlgen.CountPostsOfSiteParams{
SiteID: siteID,
PostFilter: filter,
})
}
Note: check the generated sqlgen.CountPostsOfSiteParams struct name and fields after sqlc generate — adjust if the field names differ.
- Step 5: Run test to verify it passes
Run: go test ./providers/db/ -run TestProvider_Posts/count_posts_of_site -v
Expected: PASS
- Step 6: Run all tests
Run: go test ./...
Expected: All pass.
- Step 7: Commit
git add sql/queries/posts.sql providers/db/posts.go providers/db/provider_test.go providers/db/gen/sqlgen/
git commit -m "feat: add CountPostsOfSite query and DB method"
Task 4: Add models.PageInfo type
Files:
-
Create:
models/paging.go -
Step 1: Create
models/paging.go
package models
// PageInfo carries pagination state for templates.
type PageInfo struct {
CurrentPage int
TotalPages int
PostsPerPage int
}
// HasPrevious returns true if there is a previous page.
func (p PageInfo) HasPrevious() bool {
return p.CurrentPage > 1
}
// HasNext returns true if there is a next page.
func (p PageInfo) HasNext() bool {
return p.CurrentPage < p.TotalPages
}
// PreviousPage returns the previous page number.
func (p PageInfo) PreviousPage() int {
return p.CurrentPage - 1
}
// NextPage returns the next page number.
func (p PageInfo) NextPage() int {
return p.CurrentPage + 1
}
- Step 2: Run tests
Run: go test ./...
Expected: All pass (no tests yet for this type, but it should compile).
- Step 3: Commit
git add models/paging.go
git commit -m "feat: add PageInfo model for pagination"
Task 5: Add pagination to admin post list (service + handler)
Files:
-
Modify:
services/posts/list.go:15-38(ListPosts signature and implementation) -
Modify:
handlers/posts.go:18-39(Index handler) -
Step 1: Update
ListPoststo accept paging params and return count
Replace services/posts/list.go ListPosts method:
type ListPostsResult struct {
Posts []*PostWithCategories
TotalCount int64
}
func (s *Service) ListPosts(ctx context.Context, showDeleted bool, paging db.PagingParams) (ListPostsResult, error) {
site, ok := models.GetSite(ctx)
if !ok {
return ListPostsResult{}, models.SiteRequiredError
}
posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, paging)
if err != nil {
return ListPostsResult{}, err
}
count, err := s.db.CountPostsOfSite(ctx, site.ID, showDeleted)
if err != nil {
return ListPostsResult{}, err
}
result := make([]*PostWithCategories, len(posts))
for i, post := range posts {
cats, err := s.db.SelectCategoriesOfPost(ctx, post.ID)
if err != nil {
return ListPostsResult{}, err
}
result[i] = &PostWithCategories{Post: post, Categories: cats}
}
return ListPostsResult{Posts: result, TotalCount: count}, nil
}
- Step 2: Update the admin handler
Replace handlers/posts.go Index method:
func (ph PostsHandler) Index(c fiber.Ctx) error {
var req struct {
Filter string `query:"filter"`
Page int `query:"page"`
}
if err := c.Bind().Query(&req); err != nil {
return fiber.ErrBadRequest
}
const perPage = 25
if req.Page < 1 {
req.Page = 1
}
result, err := ph.PostService.ListPosts(c.Context(), req.Filter == "deleted", db.PagingParams{
Offset: int64((req.Page - 1) * perPage),
Limit: perPage,
})
if err != nil {
return err
}
totalPages := int(result.TotalCount+int64(perPage)-1) / perPage
if totalPages < 1 {
totalPages = 1
}
pageInfo := models.PageInfo{
CurrentPage: req.Page,
TotalPages: totalPages,
PostsPerPage: perPage,
}
return accepts(c, json(func() any {
return result.Posts
}), html(func(c fiber.Ctx) error {
return c.Render("posts/index", fiber.Map{
"req": req,
"posts": result.Posts,
"pageInfo": pageInfo,
})
}))
}
Note: add "lmika.dev/lmika/weiro/providers/db" and "lmika.dev/lmika/weiro/models" to imports in handlers/posts.go.
- Step 3: Verify it compiles
Run: go build ./...
Expected: Compiles successfully.
- Step 4: Run tests
Run: go test ./...
Expected: All pass.
- Step 5: Commit
git add services/posts/list.go handlers/posts.go
git commit -m "feat: add pagination to admin post list handler and service"
Task 6: Add pagination UI to admin post list template
Files:
-
Modify:
views/posts/index.html -
Step 1: Add pagination controls to admin template
Add pagination controls after the post list in views/posts/index.html. Insert before the closing </main> tag:
{{ if gt .pageInfo.TotalPages 1 }}
<nav aria-label="Page navigation" class="my-4">
<ul class="pagination justify-content-center">
<li class="page-item{{ if not .pageInfo.HasPrevious }} disabled{{ end }}">
<a class="page-link" href="?page={{ .pageInfo.PreviousPage }}{{ if .req.Filter }}&filter={{ .req.Filter }}{{ end }}">Previous</a>
</li>
{{ range $p := .pageInfo.Pages }}
<li class="page-item{{ if eq $p $.pageInfo.CurrentPage }} active{{ end }}">
<a class="page-link" href="?page={{ $p }}{{ if $.req.Filter }}&filter={{ $.req.Filter }}{{ end }}">{{ $p }}</a>
</li>
{{ end }}
<li class="page-item{{ if not .pageInfo.HasNext }} disabled{{ end }}">
<a class="page-link" href="?page={{ .pageInfo.NextPage }}{{ if .req.Filter }}&filter={{ .req.Filter }}{{ end }}">Next</a>
</li>
</ul>
</nav>
{{ end }}
- Step 2: Add
Pagesmethod toPageInfo
Add to models/paging.go:
// Pages returns a slice of page numbers for rendering numbered pagination.
func (p PageInfo) Pages() []int {
pages := make([]int, p.TotalPages)
for i := range pages {
pages[i] = i + 1
}
return pages
}
- Step 3: Verify it compiles and test manually
Run: go build ./...
Expected: Compiles.
- Step 4: Commit
git add views/posts/index.html models/paging.go
git commit -m "feat: add pagination controls to admin post list"
Task 7: Add site settings form for PostsPerPage
Files:
-
Modify:
views/sitesettings/general.html:17-48(form) -
Modify:
services/sites/services.go:131-158(UpdateSiteSettingsParams and UpdateSiteSettings) -
Step 1: Add
PostsPerPagetoUpdateSiteSettingsParams
In services/sites/services.go, update the struct (line 131):
type UpdateSiteSettingsParams struct {
SiteID int64 `form:"siteID"`
Name string `form:"name"`
Tagline string `form:"tagline"`
Timezone string `form:"timezone"`
PostsPerPage int `form:"postsPerPage"`
}
- Step 2: Update
UpdateSiteSettingsto handlePostsPerPage
In services/sites/services.go, update UpdateSiteSettings (line 138) to validate and set the new field:
func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSettingsParams) (models.Site, error) {
site, err := s.GetSiteByID(ctx, params.SiteID)
if err != nil {
return models.Site{}, err
}
_, err = time.LoadLocation(params.Timezone)
if err != nil {
return models.Site{}, errors.Wrap(err, "invalid timezone")
}
postsPerPage := params.PostsPerPage
if postsPerPage < 1 {
postsPerPage = 1
} else if postsPerPage > 100 {
postsPerPage = 100
}
site.Title = params.Name
site.Tagline = params.Tagline
site.Timezone = params.Timezone
site.PostsPerPage = postsPerPage
if err := s.db.SaveSite(ctx, &site); err != nil {
return models.Site{}, err
}
return site, nil
}
- Step 3: Add form field to settings template
In views/sitesettings/general.html, add after the Timezone field (after line 43, before the submit button row):
<div class="row mb-3">
<label for="postsPerPage" class="col-sm-3 col-form-label text-end">Posts Per Page</label>
<div class="col-sm-3">
<input type="number" class="form-control" id="postsPerPage" name="postsPerPage" value="{{ .site.PostsPerPage }}" min="1" max="100">
<div class="form-text">Number of posts per page on the generated site.</div>
</div>
</div>
- Step 4: Verify it compiles
Run: go build ./...
Expected: Compiles.
- Step 5: Commit
git add services/sites/services.go views/sitesettings/general.html
git commit -m "feat: add posts per page setting to site settings"
Task 8: Add pagination to generated site post list
Files:
-
Modify:
providers/sitebuilder/tmpls.go:62-65(postListData) -
Modify:
providers/sitebuilder/builder.go:124-146(renderPostListWithCategories) -
Modify:
layouts/simplecss/templates/posts_list.html -
Step 1: Update
postListDatato includePageInfo
In providers/sitebuilder/tmpls.go, update postListData (line 62):
type postListData struct {
commonData
Posts []postSingleData
PageInfo models.PageInfo
PrevURL string
NextURL string
}
- Step 2: Rewrite
renderPostListWithCategoriesto paginate
Replace renderPostListWithCategories in providers/sitebuilder/builder.go (line 124):
func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Context) error {
// Collect all posts
var allPosts []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
}
allPosts = append(allPosts, rp)
}
postsPerPage := b.site.PostsPerPage
if postsPerPage < 1 {
postsPerPage = 10
}
totalPages := (len(allPosts) + postsPerPage - 1) / postsPerPage
if totalPages < 1 {
totalPages = 1
}
for page := 1; page <= totalPages; page++ {
start := (page - 1) * postsPerPage
end := start + postsPerPage
if end > len(allPosts) {
end = len(allPosts)
}
pageInfo := models.PageInfo{
CurrentPage: page,
TotalPages: totalPages,
PostsPerPage: postsPerPage,
}
var prevURL, nextURL string
if page > 1 {
if page == 2 {
prevURL = "/posts/"
} else {
prevURL = fmt.Sprintf("/posts/page/%d/", page-1)
}
}
if page < totalPages {
nextURL = fmt.Sprintf("/posts/page/%d/", page+1)
}
pl := postListData{
commonData: commonData{Site: b.site},
Posts: allPosts[start:end],
PageInfo: pageInfo,
PrevURL: prevURL,
NextURL: nextURL,
}
// Determine output path(s) for this page
var paths []string
if page == 1 {
// Page 1 renders at both root and /posts/
paths = []string{"", "/posts"}
} else {
paths = []string{fmt.Sprintf("/posts/page/%d", page)}
}
for _, path := range paths {
if err := b.createAtPath(bctx, path, func(f io.Writer) error {
return b.renderTemplate(f, tmplNamePostList, pl)
}); err != nil {
return err
}
}
}
return nil
}
- Step 3: Update the post list template with prev/next links
Replace layouts/simplecss/templates/posts_list.html:
{{ range .Posts }}
<div class="h-entry">
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
{{ .HTML }}
{{ template "_post_meta.html" . }}
</div>
{{ end }}
{{ if or .PrevURL .NextURL }}
<nav class="pagination">
{{ if .PrevURL }}<a href="{{ .PrevURL }}">← Newer posts</a>{{ end }}
{{ if .NextURL }}<a href="{{ .NextURL }}">Older posts →</a>{{ end }}
</nav>
{{ end }}
- Step 4: Run tests
Run: go test ./...
Expected: Existing builder test may need updating (see next step).
- Step 5: Update builder test
The test in providers/sitebuilder/builder_test.go creates a pubmodel.Site without PostsPerPage, which will default to 0. Update the test site to set PostsPerPage:
site := pubmodel.Site{
Site: models.Site{PostsPerPage: 10},
BaseURL: "https://example.com",
PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] {
// ... existing code ...
},
}
The expected index.html content stays the same since both posts fit on one page.
- Step 6: Run tests
Run: go test ./...
Expected: All pass.
- Step 7: Commit
git add providers/sitebuilder/tmpls.go providers/sitebuilder/builder.go layouts/simplecss/templates/posts_list.html providers/sitebuilder/builder_test.go
git commit -m "feat: add pagination to generated site post list"
Task 9: Add pagination to generated site category pages
Files:
-
Modify:
providers/sitebuilder/tmpls.go:82-88(categorySingleData) -
Modify:
providers/sitebuilder/builder.go:315-362(renderCategoryPages) -
Modify:
layouts/simplecss/templates/categories_single.html -
Step 1: Update
categorySingleDatato include pagination
In providers/sitebuilder/tmpls.go, update categorySingleData (line 82):
type categorySingleData struct {
commonData
Category *models.Category
DescriptionHTML template.HTML
Posts []postSingleData
Path string
PageInfo models.PageInfo
PrevURL string
NextURL string
}
- Step 2: Rewrite
renderCategoryPagesto paginate
Replace renderCategoryPages in providers/sitebuilder/builder.go (line 315):
func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) error {
for _, cwc := range b.site.Categories {
if cwc.PostCount == 0 {
continue
}
// Collect all posts for this category
var allPosts []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
}
allPosts = append(allPosts, rp)
}
var descHTML bytes.Buffer
if cwc.Description != "" {
if err := b.mdRenderer.RenderTo(goCtx, &descHTML, cwc.Description); err != nil {
return err
}
}
postsPerPage := b.site.PostsPerPage
if postsPerPage < 1 {
postsPerPage = 10
}
totalPages := (len(allPosts) + postsPerPage - 1) / postsPerPage
if totalPages < 1 {
totalPages = 1
}
basePath := fmt.Sprintf("/categories/%s", cwc.Slug)
for page := 1; page <= totalPages; page++ {
start := (page - 1) * postsPerPage
end := start + postsPerPage
if end > len(allPosts) {
end = len(allPosts)
}
pageInfo := models.PageInfo{
CurrentPage: page,
TotalPages: totalPages,
PostsPerPage: postsPerPage,
}
var prevURL, nextURL string
if page > 1 {
if page == 2 {
prevURL = basePath + "/"
} else {
prevURL = fmt.Sprintf("%s/page/%d/", basePath, page-1)
}
}
if page < totalPages {
nextURL = fmt.Sprintf("%s/page/%d/", basePath, page+1)
}
path := basePath
if page > 1 {
path = fmt.Sprintf("%s/page/%d", basePath, page)
}
data := categorySingleData{
commonData: commonData{Site: b.site},
Category: &cwc.Category,
DescriptionHTML: template.HTML(descHTML.String()),
Posts: allPosts[start:end],
Path: path,
PageInfo: pageInfo,
PrevURL: prevURL,
NextURL: nextURL,
}
if err := b.createAtPath(ctx, path, func(f io.Writer) error {
return b.renderTemplate(f, tmplNameCategorySingle, data)
}); err != nil {
return err
}
}
// Per-category feeds (use all posts, not paginated)
if err := b.renderCategoryFeed(ctx, cwc, allPosts); err != nil {
return err
}
}
return nil
}
- Step 3: Update category single template with prev/next links
Replace layouts/simplecss/templates/categories_single.html:
{{ if .DescriptionHTML }}<div class="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 }}
{{ if or .PrevURL .NextURL }}
<nav class="pagination">
{{ if .PrevURL }}<a href="{{ .PrevURL }}">← Newer posts</a>{{ end }}
{{ if .NextURL }}<a href="{{ .NextURL }}">Older posts →</a>{{ end }}
</nav>
{{ end }}
Note: check the current content of categories_single.html first — preserve any existing structure (like <h2> headings) that may not have been captured in the exploration. Read the file before editing.
- Step 4: Run tests
Run: go test ./...
Expected: All pass.
- Step 5: Commit
git add providers/sitebuilder/tmpls.go providers/sitebuilder/builder.go layouts/simplecss/templates/categories_single.html
git commit -m "feat: add pagination to generated site category pages"
Task 10: Final verification
- Step 1: Run full test suite
Run: go test ./...
Expected: All tests pass.
- Step 2: Build the project
Run: go build ./...
Expected: Clean build with no errors.
- Step 3: Commit any remaining changes
If any files were missed, stage and commit them.