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