30 KiB
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 tablesql/queries/pages.sql- sqlc queries for pagesmodels/pages.go- Page model struct and slug helperproviders/db/pages.go- DB provider methods for pagesservices/pages/service.go- Pages service layerhandlers/pages.go- Admin pages handlerviews/pages/index.html- Admin page list with drag-and-dropviews/pages/edit.html- Admin page edit form (two-column)assets/js/controllers/pagelist.js- Stimulus controller for drag-and-drop reorderlayouts/simplecss/templates/pages_single.html- Generated site page templateproviders/sitebuilder/render_pages.go- Builder renderPages method
Modified files:
providers/db/gen/sqlgen/- Regenerated sqlc outputmodels/pubmodel/sites.go- AddPages []models.Pagefieldservices/publisher/service.go- Fetch pages and populate pubmodelproviders/sitebuilder/tmpls.go- Add pageSingleData type and template constantproviders/sitebuilder/builder.go- Call renderPages after eg.Wait()providers/sitebuilder/builder_test.go- Add pages to testviews/_common/nav.html- Add "Pages" nav itemservices/services.go- Wire up pages servicecmds/server.go- Wire up pages handler and routesassets/js/main.js- Register pagelist controlleresbuild.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/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/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
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
// 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
// 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
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().
// 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
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.
// 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:
var SlugConflictError = errors.New("a category with this slug already exists")
To:
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:
Pages *pages.Service
Add to New():
pagesService := pages.New(dbp, publisherQueue)
And include in the return struct:
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
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.
// 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:
pgh := handlers.PagesHandler{PageService: svcs.Pages}
And routes on siteGroup:
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
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):
<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
<!-- 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;">☰</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
// 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:
import PagelistController from "./controllers/pagelist";
And register:
Stimulus.register("pagelist", PagelistController);
- Step 5: Rebuild JS bundle
Run: node esbuild.mjs
Expected: Clean build, static/assets/main.js updated.
- Step 6: Commit
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.
<!-- 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
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:
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:
// 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:
Pages: sitePages,
- Step 3: Verify it compiles
Run: go build ./...
Expected: Clean compile.
- Step 4: Commit
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:
// tmplNamePageSingle is the template for a single page (pageSingleData)
tmplNamePageSingle = "pages_single.html"
And the data struct:
type pageSingleData struct {
commonData
Page *models.Page
HTML template.HTML
}
- Step 2: Create the renderPages method
// 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:
return eg.Wait()
With:
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
<!-- 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:
"pages_single.html": {Data: []byte(`{{ if .Page.Title }}<h2>{{ .Page.Title }}</h2>{{ end }}{{ .HTML }}`)},
Add pages to the site struct:
Pages: []*models.Page{
{Title: "About", Slug: "about", Body: "About this site"},
},
Add to wantFiles:
"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
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.