weiro/docs/superpowers/plans/2026-03-22-pages.md

1219 lines
30 KiB
Markdown
Raw Normal View History

# Arbitrary Pages 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:** Allow users to create arbitrary pages with title, slug, markdown body, page type, nav visibility, and sort order, rendered on the generated site.
**Architecture:** New `pages` table + model + service + handler + admin views following the existing categories pattern. Publisher populates `pubmodel.Site.Pages`, and the site builder renders pages **after** all other content so conflicting slugs silently override auto-generated files. Drag-and-drop reordering in admin via a new Stimulus controller.
**Tech Stack:** Go/Fiber v3, SQLite/sqlc, Bootstrap 5, Stimulus.js, goldmark markdown, html/template
---
## File Structure
**New files:**
- `sql/schema/06_pages.up.sql` - Migration for pages table
- `sql/queries/pages.sql` - sqlc queries for pages
- `models/pages.go` - Page model struct and slug helper
- `providers/db/pages.go` - DB provider methods for pages
- `services/pages/service.go` - Pages service layer
- `handlers/pages.go` - Admin pages handler
- `views/pages/index.html` - Admin page list with drag-and-drop
- `views/pages/edit.html` - Admin page edit form (two-column)
- `assets/js/controllers/pagelist.js` - Stimulus controller for drag-and-drop reorder
- `layouts/simplecss/templates/pages_single.html` - Generated site page template
- `providers/sitebuilder/render_pages.go` - Builder renderPages method
**Modified files:**
- `providers/db/gen/sqlgen/` - Regenerated sqlc output
- `models/pubmodel/sites.go` - Add `Pages []models.Page` field
- `services/publisher/service.go` - Fetch pages and populate pubmodel
- `providers/sitebuilder/tmpls.go` - Add pageSingleData type and template constant
- `providers/sitebuilder/builder.go` - Call renderPages after eg.Wait()
- `providers/sitebuilder/builder_test.go` - Add pages to test
- `views/_common/nav.html` - Add "Pages" nav item
- `services/services.go` - Wire up pages service
- `cmds/server.go` - Wire up pages handler and routes
- `assets/js/main.js` - Register pagelist controller
- `esbuild.mjs` - No change needed (auto-picks up new JS files)
---
### Task 1: Schema Migration and sqlc Queries
**Files:**
- Create: `sql/schema/06_pages.up.sql`
- Create: `sql/queries/pages.sql`
- Regenerate: `providers/db/gen/sqlgen/`
- [ ] **Step 1: Write the schema migration**
```sql
-- sql/schema/06_pages.up.sql
CREATE TABLE pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL,
guid TEXT NOT NULL,
title TEXT NOT NULL,
slug TEXT NOT NULL,
body TEXT NOT NULL,
page_type INTEGER NOT NULL DEFAULT 0,
show_in_nav INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE
);
CREATE INDEX idx_pages_site ON pages (site_id);
CREATE UNIQUE INDEX idx_pages_guid ON pages (guid);
CREATE UNIQUE INDEX idx_pages_site_slug ON pages (site_id, slug);
```
- [ ] **Step 2: Write the sqlc queries**
```sql
-- sql/queries/pages.sql
-- name: SelectPagesOfSite :many
SELECT * FROM pages
WHERE site_id = ? ORDER BY sort_order ASC;
-- name: SelectPage :one
SELECT * FROM pages WHERE id = ? LIMIT 1;
-- name: SelectPageByGUID :one
SELECT * FROM pages WHERE guid = ? LIMIT 1;
-- name: SelectPageBySlugAndSite :one
SELECT * FROM pages WHERE site_id = ? AND slug = ? LIMIT 1;
-- name: InsertPage :one
INSERT INTO pages (
site_id, guid, title, slug, body, page_type, show_in_nav, sort_order, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id;
-- name: UpdatePage :exec
UPDATE pages SET
title = ?,
slug = ?,
body = ?,
page_type = ?,
show_in_nav = ?,
updated_at = ?
WHERE id = ?;
-- name: UpdatePageSortOrder :exec
UPDATE pages SET sort_order = ? WHERE id = ?;
-- name: DeletePage :exec
DELETE FROM pages WHERE id = ?;
```
- [ ] **Step 3: Regenerate sqlc**
Run: `sqlc generate`
Expected: Clean generation, new files in `providers/db/gen/sqlgen/` for pages queries.
- [ ] **Step 4: Commit**
```bash
git add sql/ providers/db/gen/
git commit -m "feat(pages): add pages table schema and sqlc queries"
```
---
### Task 2: Page Model
**Files:**
- Create: `models/pages.go`
- [ ] **Step 1: Write the Page model and constants**
```go
// models/pages.go
package models
import (
"strings"
"time"
"unicode"
)
const (
PageTypeNormal = 0
)
type Page struct {
ID int64 `json:"id"`
SiteID int64 `json:"site_id"`
GUID string `json:"guid"`
Title string `json:"title"`
Slug string `json:"slug"`
Body string `json:"body"`
PageType int `json:"page_type"`
ShowInNav bool `json:"show_in_nav"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// GeneratePageSlug creates a URL-safe slug from a page title.
// e.g. "About Me" -> "about-me"
func GeneratePageSlug(title string) string {
var sb strings.Builder
prevDash := false
for _, c := range strings.TrimSpace(title) {
if unicode.IsLetter(c) || unicode.IsNumber(c) {
sb.WriteRune(unicode.ToLower(c))
prevDash = false
} else if unicode.IsSpace(c) || c == '-' || c == '_' {
if !prevDash && sb.Len() > 0 {
sb.WriteRune('-')
prevDash = true
}
}
}
result := sb.String()
return strings.TrimRight(result, "-")
}
```
- [ ] **Step 2: Write a test for GeneratePageSlug**
```go
// models/pages_test.go
package models_test
import (
"testing"
"github.com/stretchr/testify/assert"
"lmika.dev/lmika/weiro/models"
)
func TestGeneratePageSlug(t *testing.T) {
tests := []struct {
title string
want string
}{
{"About Me", "about-me"},
{" Contact Us ", "contact-us"},
{"Hello---World", "hello-world"},
{"FAQ", "faq"},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.title, func(t *testing.T) {
assert.Equal(t, tt.want, models.GeneratePageSlug(tt.title))
})
}
}
```
- [ ] **Step 3: Run tests**
Run: `go test ./models/ -run TestGeneratePageSlug -v`
Expected: PASS
- [ ] **Step 4: Commit**
```bash
git add models/pages.go models/pages_test.go
git commit -m "feat(pages): add Page model and slug generator"
```
---
### Task 3: DB Provider for Pages
**Files:**
- Create: `providers/db/pages.go`
- [ ] **Step 1: Write the DB provider methods**
Follow the pattern from `providers/db/categories.go`. The conversion function maps sqlgen types to model types. `ShowInNav` maps from `int64` (0/1) to `bool`. Timestamps map via `time.Unix(row.CreatedAt, 0).UTC()`.
```go
// providers/db/pages.go
package db
import (
"context"
"time"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
)
func (db *Provider) SelectPagesOfSite(ctx context.Context, siteID int64) ([]*models.Page, error) {
rows, err := db.queries.SelectPagesOfSite(ctx, siteID)
if err != nil {
return nil, err
}
pages := make([]*models.Page, len(rows))
for i, row := range rows {
pages[i] = dbPageToPage(row)
}
return pages, nil
}
func (db *Provider) SelectPage(ctx context.Context, id int64) (*models.Page, error) {
row, err := db.queries.SelectPage(ctx, id)
if err != nil {
return nil, err
}
return dbPageToPage(row), nil
}
func (db *Provider) SelectPageByGUID(ctx context.Context, guid string) (*models.Page, error) {
row, err := db.queries.SelectPageByGUID(ctx, guid)
if err != nil {
return nil, err
}
return dbPageToPage(row), nil
}
func (db *Provider) SelectPageBySlugAndSite(ctx context.Context, siteID int64, slug string) (*models.Page, error) {
row, err := db.queries.SelectPageBySlugAndSite(ctx, sqlgen.SelectPageBySlugAndSiteParams{
SiteID: siteID,
Slug: slug,
})
if err != nil {
return nil, err
}
return dbPageToPage(row), nil
}
func (db *Provider) SavePage(ctx context.Context, page *models.Page) error {
if page.ID == 0 {
showInNav := int64(0)
if page.ShowInNav {
showInNav = 1
}
newID, err := db.queries.InsertPage(ctx, sqlgen.InsertPageParams{
SiteID: page.SiteID,
Guid: page.GUID,
Title: page.Title,
Slug: page.Slug,
Body: page.Body,
PageType: int64(page.PageType),
ShowInNav: showInNav,
SortOrder: int64(page.SortOrder),
CreatedAt: timeToInt(page.CreatedAt),
UpdatedAt: timeToInt(page.UpdatedAt),
})
if err != nil {
return err
}
page.ID = newID
return nil
}
showInNav := int64(0)
if page.ShowInNav {
showInNav = 1
}
return db.queries.UpdatePage(ctx, sqlgen.UpdatePageParams{
Title: page.Title,
Slug: page.Slug,
Body: page.Body,
PageType: int64(page.PageType),
ShowInNav: showInNav,
UpdatedAt: timeToInt(page.UpdatedAt),
ID: page.ID,
})
}
func (db *Provider) UpdatePageSortOrder(ctx context.Context, id int64, sortOrder int) error {
return db.queries.UpdatePageSortOrder(ctx, sqlgen.UpdatePageSortOrderParams{
SortOrder: int64(sortOrder),
ID: id,
})
}
func (db *Provider) DeletePage(ctx context.Context, id int64) error {
return db.queries.DeletePage(ctx, id)
}
func dbPageToPage(row sqlgen.Page) *models.Page {
return &models.Page{
ID: row.ID,
SiteID: row.SiteID,
GUID: row.Guid,
Title: row.Title,
Slug: row.Slug,
Body: row.Body,
PageType: int(row.PageType),
ShowInNav: row.ShowInNav != 0,
SortOrder: int(row.SortOrder),
CreatedAt: time.Unix(row.CreatedAt, 0).UTC(),
UpdatedAt: time.Unix(row.UpdatedAt, 0).UTC(),
}
}
```
**Important:** The exact field names on `sqlgen.InsertPageParams`, `sqlgen.UpdatePageParams`, etc. depend on what sqlc generates. Check the generated code in `providers/db/gen/sqlgen/pages.sql.go` to confirm field names and types before writing this file. Adjust as needed.
- [ ] **Step 2: Verify it compiles**
Run: `go build ./providers/db/...`
Expected: Clean compile
- [ ] **Step 3: Commit**
```bash
git add providers/db/pages.go
git commit -m "feat(pages): add DB provider methods for pages"
```
---
### Task 4: Pages Service
**Files:**
- Create: `services/pages/service.go`
- Modify: `services/services.go`
- [ ] **Step 1: Write the pages service**
Follow the pattern from `services/categories/service.go`. The service gets site from context, validates ownership, generates slugs, and queues republish on mutations.
```go
// services/pages/service.go
package pages
import (
"context"
"time"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db"
"lmika.dev/lmika/weiro/services/publisher"
)
type CreatePageParams struct {
GUID string `form:"guid" json:"guid"`
Title string `form:"title" json:"title"`
Slug string `form:"slug" json:"slug"`
Body string `form:"body" json:"body"`
PageType int `form:"page_type" json:"page_type"`
ShowInNav bool `form:"show_in_nav" json:"show_in_nav"`
}
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) ListPages(ctx context.Context) ([]*models.Page, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
}
return s.db.SelectPagesOfSite(ctx, site.ID)
}
func (s *Service) GetPage(ctx context.Context, id int64) (*models.Page, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
}
page, err := s.db.SelectPage(ctx, id)
if err != nil {
return nil, err
}
if page.SiteID != site.ID {
return nil, models.NotFoundError
}
return page, nil
}
func (s *Service) CreatePage(ctx context.Context, params CreatePageParams) (*models.Page, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
}
now := time.Now()
slug := params.Slug
if slug == "" {
slug = models.GeneratePageSlug(params.Title)
}
// Check slug collision
if _, err := s.db.SelectPageBySlugAndSite(ctx, site.ID, slug); err == nil {
return nil, models.SlugConflictError
} else if !db.ErrorIsNoRows(err) {
return nil, err
}
// Determine sort order: place at end
existingPages, err := s.db.SelectPagesOfSite(ctx, site.ID)
if err != nil {
return nil, err
}
sortOrder := len(existingPages)
page := &models.Page{
SiteID: site.ID,
GUID: params.GUID,
Title: params.Title,
Slug: slug,
Body: params.Body,
PageType: params.PageType,
ShowInNav: params.ShowInNav,
SortOrder: sortOrder,
CreatedAt: now,
UpdatedAt: now,
}
if page.GUID == "" {
page.GUID = models.NewNanoID()
}
if err := s.db.SavePage(ctx, page); err != nil {
return nil, err
}
s.publisher.Queue(site)
return page, nil
}
func (s *Service) UpdatePage(ctx context.Context, id int64, params CreatePageParams) (*models.Page, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
}
page, err := s.db.SelectPage(ctx, id)
if err != nil {
return nil, err
}
if page.SiteID != site.ID {
return nil, models.NotFoundError
}
slug := params.Slug
if slug == "" {
slug = models.GeneratePageSlug(params.Title)
}
// Check slug collision (exclude self)
if existing, err := s.db.SelectPageBySlugAndSite(ctx, site.ID, slug); err == nil && existing.ID != page.ID {
return nil, models.SlugConflictError
} else if err != nil && !db.ErrorIsNoRows(err) {
return nil, err
}
page.Title = params.Title
page.Slug = slug
page.Body = params.Body
page.PageType = params.PageType
page.ShowInNav = params.ShowInNav
page.UpdatedAt = time.Now()
if err := s.db.SavePage(ctx, page); err != nil {
return nil, err
}
s.publisher.Queue(site)
return page, nil
}
func (s *Service) DeletePage(ctx context.Context, id int64) error {
site, ok := models.GetSite(ctx)
if !ok {
return models.SiteRequiredError
}
page, err := s.db.SelectPage(ctx, id)
if err != nil {
return err
}
if page.SiteID != site.ID {
return models.NotFoundError
}
if err := s.db.DeletePage(ctx, id); err != nil {
return err
}
s.publisher.Queue(site)
return nil
}
func (s *Service) ReorderPages(ctx context.Context, pageIDs []int64) error {
site, ok := models.GetSite(ctx)
if !ok {
return models.SiteRequiredError
}
// Verify all pages belong to this site
for i, id := range pageIDs {
page, err := s.db.SelectPage(ctx, id)
if err != nil {
return err
}
if page.SiteID != site.ID {
return models.NotFoundError
}
if err := s.db.UpdatePageSortOrder(ctx, id, i); err != nil {
return err
}
}
s.publisher.Queue(site)
return nil
}
```
- [ ] **Step 2: Make SlugConflictError generic**
In `models/errors.go`, change:
```go
var SlugConflictError = errors.New("a category with this slug already exists")
```
To:
```go
var SlugConflictError = errors.New("a record with this slug already exists")
```
- [ ] **Step 3: Wire up the service in services/services.go**
Add to the `Services` struct:
```go
Pages *pages.Service
```
Add to `New()`:
```go
pagesService := pages.New(dbp, publisherQueue)
```
And include in the return struct:
```go
Pages: pagesService,
```
Add import: `"lmika.dev/lmika/weiro/services/pages"`
- [ ] **Step 4: Verify it compiles**
Run: `go build ./services/...`
Expected: Clean compile
- [ ] **Step 5: Commit**
```bash
git add services/pages/ services/services.go models/errors.go
git commit -m "feat(pages): add pages service layer"
```
---
### Task 5: Pages Handler and Routes
**Files:**
- Create: `handlers/pages.go`
- Modify: `cmds/server.go`
- [ ] **Step 1: Write the pages handler**
Follow the pattern from `handlers/categories.go` for CRUD, plus a `Reorder` handler that accepts JSON.
```go
// handlers/pages.go
package handlers
import (
"fmt"
"strconv"
"github.com/gofiber/fiber/v3"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/services/pages"
)
type PagesHandler struct {
PageService *pages.Service
}
func (ph PagesHandler) Index(c fiber.Ctx) error {
pagesList, err := ph.PageService.ListPages(c.Context())
if err != nil {
return err
}
return c.Render("pages/index", fiber.Map{
"pages": pagesList,
})
}
func (ph PagesHandler) New(c fiber.Ctx) error {
page := models.Page{
GUID: models.NewNanoID(),
}
return c.Render("pages/edit", fiber.Map{
"page": page,
"isNew": true,
"bodyClass": "page-edit-page",
})
}
func (ph PagesHandler) Edit(c fiber.Ctx) error {
pageID, err := strconv.ParseInt(c.Params("pageID"), 10, 64)
if err != nil {
return fiber.ErrBadRequest
}
page, err := ph.PageService.GetPage(c.Context(), pageID)
if err != nil {
return err
}
return c.Render("pages/edit", fiber.Map{
"page": page,
"isNew": false,
"bodyClass": "page-edit-page",
})
}
func (ph PagesHandler) Create(c fiber.Ctx) error {
var req pages.CreatePageParams
if err := c.Bind().Body(&req); err != nil {
return err
}
_, err := ph.PageService.CreatePage(c.Context(), req)
if err != nil {
return err
}
site := models.MustGetSite(c.Context())
return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID))
}
func (ph PagesHandler) Update(c fiber.Ctx) error {
pageID, err := strconv.ParseInt(c.Params("pageID"), 10, 64)
if err != nil {
return fiber.ErrBadRequest
}
var req pages.CreatePageParams
if err := c.Bind().Body(&req); err != nil {
return err
}
_, err = ph.PageService.UpdatePage(c.Context(), pageID, req)
if err != nil {
return err
}
site := models.MustGetSite(c.Context())
return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID))
}
func (ph PagesHandler) Delete(c fiber.Ctx) error {
pageID, err := strconv.ParseInt(c.Params("pageID"), 10, 64)
if err != nil {
return fiber.ErrBadRequest
}
if err := ph.PageService.DeletePage(c.Context(), pageID); err != nil {
return err
}
site := models.MustGetSite(c.Context())
return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID))
}
func (ph PagesHandler) Reorder(c fiber.Ctx) error {
var req struct {
PageIDs []int64 `json:"page_ids"`
}
if err := c.Bind().Body(&req); err != nil {
return err
}
if err := ph.PageService.ReorderPages(c.Context(), req.PageIDs); err != nil {
return err
}
return c.JSON(fiber.Map{"ok": true})
}
```
- [ ] **Step 2: Register routes in cmds/server.go**
After the categories route block (~line 150), add:
```go
pgh := handlers.PagesHandler{PageService: svcs.Pages}
```
And routes on `siteGroup`:
```go
siteGroup.Get("/pages", pgh.Index)
siteGroup.Get("/pages/new", pgh.New)
siteGroup.Get("/pages/:pageID", pgh.Edit)
siteGroup.Post("/pages", pgh.Create)
siteGroup.Post("/pages/reorder", pgh.Reorder)
siteGroup.Post("/pages/:pageID", pgh.Update)
siteGroup.Post("/pages/:pageID/delete", pgh.Delete)
```
Add import: `// already imported via handlers package`
- [ ] **Step 3: Verify it compiles**
Run: `go build ./...`
Expected: Clean compile
- [ ] **Step 4: Commit**
```bash
git add handlers/pages.go cmds/server.go
git commit -m "feat(pages): add pages handler and admin routes"
```
---
### Task 6: Admin Views - Page List with Drag-and-Drop
**Files:**
- Create: `views/pages/index.html`
- Create: `assets/js/controllers/pagelist.js`
- Modify: `assets/js/main.js`
- Modify: `views/_common/nav.html`
- [ ] **Step 1: Add "Pages" to the admin nav bar**
In `views/_common/nav.html`, add a new `<li>` after the Categories nav item (after line 14):
```html
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/pages">Pages</a>
</li>
```
- [ ] **Step 2: Write the page list view**
```html
<!-- views/pages/index.html -->
<main class="container">
<div class="my-4 d-flex justify-content-between align-items-baseline">
<div>
<a href="/sites/{{ .site.ID }}/pages/new" class="btn btn-success">New Page</a>
</div>
</div>
{{ if .pages }}
<table class="table" data-controller="pagelist" data-pagelist-site-id-value="{{ .site.ID }}">
<thead>
<tr>
<th style="width: 2rem;"></th>
<th>Title</th>
<th>Slug</th>
<th>Nav</th>
</tr>
</thead>
<tbody data-pagelist-target="list">
{{ range .pages }}
<tr draggable="true" data-page-id="{{ .ID }}"
data-action="dragstart->pagelist#dragStart dragover->pagelist#dragOver drop->pagelist#drop dragend->pagelist#dragEnd">
<td class="text-muted" style="cursor: grab;">&#x2630;</td>
<td><a href="/sites/{{ $.site.ID }}/pages/{{ .ID }}">{{ .Title }}</a></td>
<td><code>{{ .Slug }}</code></td>
<td>{{ if .ShowInNav }}Yes{{ end }}</td>
</tr>
{{ end }}
</tbody>
</table>
{{ else }}
<div class="h4 m-3 text-center">
<div class="position-absolute top-50 start-50 translate-middle">No pages yet.</div>
</div>
{{ end }}
</main>
```
- [ ] **Step 3: Write the pagelist Stimulus controller**
```javascript
// assets/js/controllers/pagelist.js
import { Controller } from "@hotwired/stimulus"
import { showToast } from "../services/toast";
export default class PagelistController extends Controller {
static values = {
siteId: Number,
};
static targets = ["list"];
dragStart(ev) {
this.draggedRow = ev.currentTarget;
ev.currentTarget.classList.add("opacity-50");
ev.dataTransfer.effectAllowed = "move";
}
dragOver(ev) {
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
}
drop(ev) {
ev.preventDefault();
const targetRow = ev.currentTarget;
if (this.draggedRow && this.draggedRow !== targetRow) {
const rows = [...this.listTarget.children];
const draggedIdx = rows.indexOf(this.draggedRow);
const targetIdx = rows.indexOf(targetRow);
if (draggedIdx < targetIdx) {
targetRow.after(this.draggedRow);
} else {
targetRow.before(this.draggedRow);
}
this.saveOrder();
}
}
dragEnd(ev) {
ev.currentTarget.classList.remove("opacity-50");
this.draggedRow = null;
}
async saveOrder() {
const rows = [...this.listTarget.children];
const pageIds = rows.map(row => parseInt(row.dataset.pageId, 10));
try {
await fetch(`/sites/${this.siteIdValue}/pages/reorder`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({ page_ids: pageIds }),
});
} catch (error) {
showToast({
title: "Error",
body: "Failed to reorder pages.",
});
}
}
}
```
- [ ] **Step 4: Register the controller in main.js**
Add to `assets/js/main.js`:
```javascript
import PagelistController from "./controllers/pagelist";
```
And register:
```javascript
Stimulus.register("pagelist", PagelistController);
```
- [ ] **Step 5: Rebuild JS bundle**
Run: `node esbuild.mjs`
Expected: Clean build, `static/assets/main.js` updated.
- [ ] **Step 6: Commit**
```bash
git add views/pages/index.html views/_common/nav.html assets/js/controllers/pagelist.js assets/js/main.js static/assets/main.js
git commit -m "feat(pages): add admin page list with drag-and-drop reorder"
```
---
### Task 7: Admin Views - Page Edit Form
**Files:**
- Create: `views/pages/edit.html`
- [ ] **Step 1: Write the page edit form**
Two-column layout mirroring the post edit form: title + body on left, slug/page type/show in nav on right sidebar.
```html
<!-- views/pages/edit.html -->
<main class="container py-2">
{{ if .isNew }}
<form method="post" action="/sites/{{ .site.ID }}/pages">
{{ else }}
<form method="post" action="/sites/{{ .site.ID }}/pages/{{ .page.ID }}">
{{ end }}
<input type="hidden" name="guid" value="{{ .page.GUID }}">
<div class="row">
<div class="col-md-9">
<div class="mb-2">
<input type="text" name="title" class="form-control" placeholder="Title" value="{{ .page.Title }}">
</div>
<div class="mb-3">
<textarea name="body" class="form-control" rows="20">{{ .page.Body }}</textarea>
</div>
<div>
<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 page?')) { document.getElementById('delete-form').submit(); }">Delete</button>
{{ end }}
</div>
</div>
<div class="col-md-3">
<div class="card mb-3">
<div class="card-header">Page Settings</div>
<div class="card-body">
<div class="mb-3">
<label for="pageSlug" class="form-label">Slug</label>
<input type="text" class="form-control" id="pageSlug" name="slug" value="{{ .page.Slug }}">
<div class="form-text">Auto-generated from title if left blank.</div>
</div>
<div class="mb-3">
<label for="pageType" class="form-label">Page Type</label>
<select class="form-select" id="pageType" name="page_type">
<option value="0" {{ if eq .page.PageType 0 }}selected{{ end }}>Normal</option>
</select>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="show_in_nav" value="true" id="showInNav"
{{ if .page.ShowInNav }}checked{{ end }}>
<label class="form-check-label" for="showInNav">Show in Nav</label>
</div>
</div>
</div>
</div>
</div>
</form>
{{ if not .isNew }}
<form id="delete-form" method="post" action="/sites/{{ .site.ID }}/pages/{{ .page.ID }}/delete" style="display:none;"></form>
{{ end }}
</main>
```
- [ ] **Step 2: Verify the app compiles and starts**
Run: `go build ./...`
Expected: Clean compile.
- [ ] **Step 3: Commit**
```bash
git add views/pages/edit.html
git commit -m "feat(pages): add admin page edit form with sidebar"
```
---
### Task 8: Publisher and pubmodel Changes
**Files:**
- Modify: `models/pubmodel/sites.go`
- Modify: `services/publisher/service.go`
- [ ] **Step 1: Add Pages field to pubmodel.Site**
In `models/pubmodel/sites.go`, add to the `Site` struct:
```go
Pages []*models.Page
```
- [ ] **Step 2: Populate pages in the publisher**
In `services/publisher/service.go`, in the `Publish` method, after fetching categories (~line 66), add:
```go
// Fetch pages
sitePages, err := p.db.SelectPagesOfSite(ctx, site.ID)
if err != nil {
return err
}
```
Then in the `pubSite` construction (~line 73), add the `Pages` field:
```go
Pages: sitePages,
```
- [ ] **Step 3: Verify it compiles**
Run: `go build ./...`
Expected: Clean compile.
- [ ] **Step 4: Commit**
```bash
git add models/pubmodel/sites.go services/publisher/service.go
git commit -m "feat(pages): populate pages in publisher for site generation"
```
---
### Task 9: Site Builder - Render Pages
**Files:**
- Create: `providers/sitebuilder/render_pages.go`
- Modify: `providers/sitebuilder/tmpls.go`
- Modify: `providers/sitebuilder/builder.go`
- Create: `layouts/simplecss/templates/pages_single.html`
- Modify: `providers/sitebuilder/builder_test.go`
- [ ] **Step 1: Add template types and constant**
In `providers/sitebuilder/tmpls.go`, add the template name constant:
```go
// tmplNamePageSingle is the template for a single page (pageSingleData)
tmplNamePageSingle = "pages_single.html"
```
And the data struct:
```go
type pageSingleData struct {
commonData
Page *models.Page
HTML template.HTML
}
```
- [ ] **Step 2: Create the renderPages method**
```go
// providers/sitebuilder/render_pages.go
package sitebuilder
import (
"bytes"
"context"
"html/template"
"io"
"lmika.dev/lmika/weiro/models"
)
func (b *Builder) renderPages(bctx buildContext) error {
for _, page := range b.site.Pages {
var md bytes.Buffer
if err := b.mdRenderer.RenderTo(context.Background(), &md, page.Body); err != nil {
return err
}
data := pageSingleData{
commonData: commonData{Site: b.site},
Page: page,
HTML: template.HTML(md.String()),
}
path := "/" + page.Slug
if err := b.createAtPath(bctx, path, func(f io.Writer) error {
return b.renderTemplate(f, tmplNamePageSingle, data)
}); err != nil {
return err
}
}
return nil
}
```
- [ ] **Step 3: Call renderPages after eg.Wait() in BuildSite**
In `providers/sitebuilder/builder.go`, modify the `BuildSite` method. Replace:
```go
return eg.Wait()
```
With:
```go
if err := eg.Wait(); err != nil {
return err
}
// Render pages last so they can override auto-generated content
return b.renderPages(buildCtx)
```
- [ ] **Step 4: Create the generated site template**
```html
<!-- layouts/simplecss/templates/pages_single.html -->
{{ if .Page.Title }}<h2>{{ .Page.Title }}</h2>{{ end }}
{{ .HTML }}
```
- [ ] **Step 5: Add pages to the builder test**
In `providers/sitebuilder/builder_test.go`, add `"pages_single.html"` to the `tmpls` MapFS:
```go
"pages_single.html": {Data: []byte(`{{ if .Page.Title }}<h2>{{ .Page.Title }}</h2>{{ end }}{{ .HTML }}`)},
```
Add pages to the `site` struct:
```go
Pages: []*models.Page{
{Title: "About", Slug: "about", Body: "About this site"},
},
```
Add to `wantFiles`:
```go
"about/index.html": "<h2>About</h2><p>About this site</p>\n",
```
- [ ] **Step 6: Run the builder test**
Run: `go test ./providers/sitebuilder/ -v`
Expected: PASS
- [ ] **Step 7: Commit**
```bash
git add providers/sitebuilder/render_pages.go providers/sitebuilder/tmpls.go providers/sitebuilder/builder.go layouts/simplecss/templates/pages_single.html providers/sitebuilder/builder_test.go
git commit -m "feat(pages): render pages in site builder after all other content"
```
---
### Task 10: Integration Test - Full Compile and Verify
**Files:** None (verification only)
- [ ] **Step 1: Run all tests**
Run: `go test ./...`
Expected: All tests pass.
- [ ] **Step 2: Verify clean build**
Run: `go build ./...`
Expected: Clean compile, no errors.
- [ ] **Step 3: Commit any fixes if needed**
Only if previous steps required adjustments.