Paging #4
888
docs/superpowers/plans/2026-03-22-paging.md
Normal file
888
docs/superpowers/plans/2026-03-22-paging.md
Normal file
|
|
@ -0,0 +1,888 @@
|
|||
# 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`:
|
||||
```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`:
|
||||
```sql
|
||||
-- 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:
|
||||
```sql
|
||||
-- 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**
|
||||
|
||||
```bash
|
||||
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 `PostsPerPage` to `models.Site`**
|
||||
|
||||
In `models/sites.go`, add to the `Site` struct (after `Timezone`):
|
||||
```go
|
||||
PostsPerPage int
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `dbSiteToSite` in `providers/db/sites.go`**
|
||||
|
||||
In `providers/db/sites.go`, update `dbSiteToSite` (line 102) to map the new field:
|
||||
```go
|
||||
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 `SaveSite` to include `PostsPerPage`**
|
||||
|
||||
In `providers/db/sites.go`, update the `InsertSite` call (line 44) to include `PostsPerPage`:
|
||||
```go
|
||||
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`:
|
||||
```go
|
||||
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**
|
||||
|
||||
```bash
|
||||
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`:
|
||||
```go
|
||||
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`:
|
||||
```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`:
|
||||
```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**
|
||||
|
||||
```bash
|
||||
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`**
|
||||
|
||||
```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**
|
||||
|
||||
```bash
|
||||
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 `ListPosts` to accept paging params and return count**
|
||||
|
||||
Replace `services/posts/list.go` `ListPosts` method:
|
||||
```go
|
||||
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:
|
||||
```go
|
||||
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**
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```html
|
||||
{{ 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 `Pages` method to `PageInfo`**
|
||||
|
||||
Add to `models/paging.go`:
|
||||
```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**
|
||||
|
||||
```bash
|
||||
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 `PostsPerPage` to `UpdateSiteSettingsParams`**
|
||||
|
||||
In `services/sites/services.go`, update the struct (line 131):
|
||||
```go
|
||||
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 `UpdateSiteSettings` to handle `PostsPerPage`**
|
||||
|
||||
In `services/sites/services.go`, update `UpdateSiteSettings` (line 138) to validate and set the new field:
|
||||
```go
|
||||
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):
|
||||
```html
|
||||
<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**
|
||||
|
||||
```bash
|
||||
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 `postListData` to include `PageInfo`**
|
||||
|
||||
In `providers/sitebuilder/tmpls.go`, update `postListData` (line 62):
|
||||
```go
|
||||
type postListData struct {
|
||||
commonData
|
||||
Posts []postSingleData
|
||||
PageInfo models.PageInfo
|
||||
PrevURL string
|
||||
NextURL string
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rewrite `renderPostListWithCategories` to paginate**
|
||||
|
||||
Replace `renderPostListWithCategories` in `providers/sitebuilder/builder.go` (line 124):
|
||||
```go
|
||||
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`:
|
||||
```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`:
|
||||
```go
|
||||
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**
|
||||
|
||||
```bash
|
||||
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 `categorySingleData` to include pagination**
|
||||
|
||||
In `providers/sitebuilder/tmpls.go`, update `categorySingleData` (line 82):
|
||||
```go
|
||||
type categorySingleData struct {
|
||||
commonData
|
||||
Category *models.Category
|
||||
DescriptionHTML template.HTML
|
||||
Posts []postSingleData
|
||||
Path string
|
||||
PageInfo models.PageInfo
|
||||
PrevURL string
|
||||
NextURL string
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rewrite `renderCategoryPages` to paginate**
|
||||
|
||||
Replace `renderCategoryPages` in `providers/sitebuilder/builder.go` (line 315):
|
||||
```go
|
||||
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`:
|
||||
```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**
|
||||
|
||||
```bash
|
||||
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.
|
||||
Loading…
Reference in a new issue