# 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 }}
    Title Slug Nav
    {{ .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.