diff --git a/docs/superpowers/plans/2026-03-22-pages.md b/docs/superpowers/plans/2026-03-22-pages.md new file mode 100644 index 0000000..89a3983 --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-pages.md @@ -0,0 +1,1218 @@ +# 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 `
  • ` after the Categories nav item (after line 14): + +```html +
  • +``` + +- [ ] **Step 2: Write the page list view** + +```html + +
    +
    +
    + New Page +
    +
    + + {{ if .pages }} + + + + + + + + + + + {{ range .pages }} + + + + + + + {{ end }} + +
    TitleSlugNav
    {{ .Title }}{{ .Slug }}{{ if .ShowInNav }}Yes{{ end }}
    + {{ else }} +
    +
    No pages yet.
    +
    + {{ end }} +
    +``` + +- [ ] **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 + +
    + {{ if .isNew }} +
    + {{ else }} + + {{ end }} + +
    +
    +
    + +
    +
    + +
    +
    + + {{ if not .isNew }} + + {{ end }} +
    +
    +
    +
    +
    Page Settings
    +
    +
    + + +
    Auto-generated from title if left blank.
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    + + {{ if not .isNew }} + + {{ end }} +
    +``` + +- [ ] **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 + +{{ if .Page.Title }}

    {{ .Page.Title }}

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

    {{ .Page.Title }}

    {{ 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": "

    About

    About this site

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