weiro/docs/superpowers/plans/2026-03-22-pages.md
Leon Mika 620ab6c6fa docs: add pages feature implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 17:47:19 +11:00

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 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/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;">&#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
// 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.