Phase 09: Content Management - 6 plan(s) in 3 wave(s) - Wave 1: 09-01 (CMS pages/layouts), 09-03 (media library) - parallel - Wave 2: 09-02 (component embedding), 09-04 (revisions), 09-05 (menus) - Wave 3: 09-06 (hot reload) - Ready for execution
525 lines
19 KiB
Markdown
525 lines
19 KiB
Markdown
---
|
|
phase: 09-content-management
|
|
plan: 05
|
|
type: execute
|
|
wave: 2
|
|
depends_on: ["09-01"]
|
|
files_modified:
|
|
- summercms/src/content/menu/Menu.scala
|
|
- summercms/src/content/menu/MenuItem.scala
|
|
- summercms/src/content/menu/MenuItemType.scala
|
|
- summercms/src/content/menu/MenuService.scala
|
|
- summercms/src/content/menu/MenuItemTypeRegistry.scala
|
|
- summercms/resources/db/migration/V012__navigation_menus.sql
|
|
- summercms/src/api/admin/MenuRoutes.scala
|
|
- summercms/src/content/cms/pebble/MenuFunction.scala
|
|
autonomous: true
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Admin can create navigation menus with unique codes"
|
|
- "Menu items support URL, CMS page reference, and plugin-generated types"
|
|
- "Menu items can be nested (parent-child hierarchy)"
|
|
- "Plugins can register custom menu item types"
|
|
- "Templates can access menus via Pebble function"
|
|
artifacts:
|
|
- path: "summercms/src/content/menu/Menu.scala"
|
|
provides: "Menu model"
|
|
contains: "case class Menu"
|
|
- path: "summercms/src/content/menu/MenuItem.scala"
|
|
provides: "Menu item with polymorphic types"
|
|
contains: "case class MenuItem"
|
|
- path: "summercms/src/content/menu/MenuItemTypeRegistry.scala"
|
|
provides: "Extensible item type registry"
|
|
contains: "trait MenuItemTypeRegistry"
|
|
- path: "summercms/resources/db/migration/V012__navigation_menus.sql"
|
|
provides: "Menu and item tables"
|
|
contains: "CREATE TABLE menus"
|
|
key_links:
|
|
- from: "MenuService.resolve"
|
|
to: "MenuItemTypeRegistry"
|
|
via: "resolves item type for URL generation"
|
|
pattern: "typeRegistry\\.resolve"
|
|
- from: "MenuFunction"
|
|
to: "MenuService"
|
|
via: "Pebble function fetches resolved menu"
|
|
pattern: "menuService\\.getResolved"
|
|
---
|
|
|
|
<objective>
|
|
Implement navigation menus with extensible item types
|
|
|
|
Purpose: Enable admin to create and manage navigation menus. Menu items support static URLs, CMS page references (dynamic), and plugin-generated items (extensible). Menus accessible in templates via Pebble function.
|
|
|
|
Output: Menu/MenuItem models, MenuItemType polymorphism, MenuItemTypeRegistry for plugin extensibility, MenuService, database migration, admin routes, Pebble menu() function.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
|
|
@/home/jin/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/phases/09-content-management/09-RESEARCH.md
|
|
@.planning/phases/09-content-management/09-01-SUMMARY.md (CmsRouter for page URL resolution)
|
|
@summercms/src/content/cms/CmsRouter.scala
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Create menu models and database migration</name>
|
|
<files>
|
|
summercms/src/content/menu/Menu.scala
|
|
summercms/src/content/menu/MenuItem.scala
|
|
summercms/src/content/menu/MenuItemType.scala
|
|
summercms/resources/db/migration/V012__navigation_menus.sql
|
|
</files>
|
|
<action>
|
|
**Menu.scala:**
|
|
```scala
|
|
case class Menu(
|
|
id: Long,
|
|
code: String, // Unique identifier for theme reference: "main", "footer"
|
|
name: String, // Display name: "Main Navigation"
|
|
createdAt: Instant,
|
|
updatedAt: Instant
|
|
)
|
|
|
|
case class ResolvedMenu(
|
|
code: String,
|
|
name: String,
|
|
items: List[ResolvedMenuItem]
|
|
)
|
|
```
|
|
|
|
**MenuItem.scala:**
|
|
```scala
|
|
case class MenuItem(
|
|
id: Long,
|
|
menuId: Long,
|
|
parentId: Option[Long], // For nested items
|
|
itemType: String, // "url", "cms-page", or plugin type
|
|
title: Option[String], // Display title (can inherit from page)
|
|
reference: Option[String], // URL or page reference or plugin key
|
|
cssClass: Option[String], // Optional CSS class
|
|
nestDepth: Int, // Computed depth for flat storage
|
|
sortOrder: Int,
|
|
isHidden: Boolean
|
|
)
|
|
|
|
case class ResolvedMenuItem(
|
|
title: String,
|
|
url: String,
|
|
isActive: Boolean, // True if current URL matches
|
|
cssClass: Option[String],
|
|
items: List[ResolvedMenuItem] // Nested children
|
|
)
|
|
```
|
|
|
|
**MenuItemType.scala:**
|
|
Sealed trait for built-in types, extensible via registry:
|
|
```scala
|
|
sealed trait MenuItemType:
|
|
def resolve(item: MenuItem, currentUrl: String, context: MenuResolveContext): IO[MenuError, ResolvedMenuItem]
|
|
|
|
object MenuItemType:
|
|
case object Url extends MenuItemType:
|
|
def resolve(item: MenuItem, currentUrl: String, context: MenuResolveContext): IO[MenuError, ResolvedMenuItem] =
|
|
ZIO.succeed(ResolvedMenuItem(
|
|
title = item.title.getOrElse("Link"),
|
|
url = item.reference.getOrElse("#"),
|
|
isActive = item.reference.contains(currentUrl),
|
|
cssClass = item.cssClass,
|
|
items = List.empty
|
|
))
|
|
|
|
case object CmsPage extends MenuItemType:
|
|
def resolve(item: MenuItem, currentUrl: String, context: MenuResolveContext): IO[MenuError, ResolvedMenuItem] =
|
|
for
|
|
pageFile <- ZIO.fromOption(item.reference).orElseFail(MenuError.MissingReference(item.id))
|
|
page <- context.cmsRouter.load(pageFile).mapError(MenuError.PageLoadFailed(_))
|
|
url <- context.cmsRouter.pageUrl(page, Map.empty)
|
|
title = item.title.orElse(page.config.title).getOrElse(page.fileName)
|
|
yield ResolvedMenuItem(
|
|
title = title,
|
|
url = url,
|
|
isActive = url == currentUrl,
|
|
cssClass = item.cssClass,
|
|
items = List.empty
|
|
)
|
|
|
|
case class MenuResolveContext(
|
|
cmsRouter: CmsRouter,
|
|
pluginResolvers: Map[String, MenuItemResolver] // Plugin-provided resolvers
|
|
)
|
|
|
|
// Interface for plugin-provided menu item types
|
|
trait MenuItemResolver:
|
|
def resolve(item: MenuItem, currentUrl: String): IO[MenuError, ResolvedMenuItem]
|
|
```
|
|
|
|
**V012__navigation_menus.sql:**
|
|
```sql
|
|
CREATE TABLE menus (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
code VARCHAR(100) UNIQUE NOT NULL,
|
|
name VARCHAR(255) NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE menu_items (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
menu_id BIGINT NOT NULL REFERENCES menus(id) ON DELETE CASCADE,
|
|
parent_id BIGINT REFERENCES menu_items(id) ON DELETE CASCADE,
|
|
item_type VARCHAR(50) NOT NULL DEFAULT 'url',
|
|
title VARCHAR(255),
|
|
reference VARCHAR(500),
|
|
css_class VARCHAR(100),
|
|
nest_depth INT NOT NULL DEFAULT 0,
|
|
sort_order INT NOT NULL DEFAULT 0,
|
|
is_hidden BOOLEAN NOT NULL DEFAULT false
|
|
);
|
|
|
|
CREATE INDEX idx_menu_items_menu ON menu_items(menu_id, sort_order);
|
|
CREATE INDEX idx_menu_items_parent ON menu_items(parent_id);
|
|
|
|
-- Insert default menus
|
|
INSERT INTO menus (code, name) VALUES
|
|
('main', 'Main Navigation'),
|
|
('footer', 'Footer Navigation');
|
|
```
|
|
</action>
|
|
<verify>
|
|
`./mill summercms.compile` succeeds with new models
|
|
Migration file syntactically valid
|
|
</verify>
|
|
<done>
|
|
Menu and MenuItem models with ResolvedMenuItem for frontend. MenuItemType trait for URL and CMS page types. Migration creates tables with default menus.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Implement MenuItemTypeRegistry and MenuService</name>
|
|
<files>
|
|
summercms/src/content/menu/MenuItemTypeRegistry.scala
|
|
summercms/src/content/menu/MenuService.scala
|
|
summercms/src/content/menu/MenuRepository.scala
|
|
</files>
|
|
<action>
|
|
**MenuItemTypeRegistry.scala:**
|
|
Registry for extensible menu item types:
|
|
```scala
|
|
trait MenuItemTypeRegistry:
|
|
def register(typeName: String, resolver: MenuItemResolver): UIO[Unit]
|
|
def resolve(typeName: String): UIO[Option[MenuItemType | MenuItemResolver]]
|
|
def listTypes: UIO[List[String]]
|
|
|
|
object MenuItemTypeRegistry:
|
|
val live: ULayer[MenuItemTypeRegistry] =
|
|
ZLayer.fromZIO {
|
|
for
|
|
registry <- Ref.make(Map[String, MenuItemType | MenuItemResolver](
|
|
"url" -> MenuItemType.Url,
|
|
"cms-page" -> MenuItemType.CmsPage
|
|
))
|
|
yield new MenuItemTypeRegistry:
|
|
def register(typeName: String, resolver: MenuItemResolver): UIO[Unit] =
|
|
registry.update(_ + (typeName -> resolver))
|
|
|
|
def resolve(typeName: String): UIO[Option[MenuItemType | MenuItemResolver]] =
|
|
registry.get.map(_.get(typeName))
|
|
|
|
def listTypes: UIO[List[String]] =
|
|
registry.get.map(_.keys.toList)
|
|
}
|
|
```
|
|
|
|
**MenuRepository.scala:**
|
|
Database operations:
|
|
```scala
|
|
trait MenuRepository:
|
|
def findByCode(code: String): IO[RepositoryError, Option[Menu]]
|
|
def findById(id: Long): IO[RepositoryError, Option[Menu]]
|
|
def listAll: IO[RepositoryError, List[Menu]]
|
|
def insert(menu: Menu): IO[RepositoryError, Menu]
|
|
def update(menu: Menu): IO[RepositoryError, Menu]
|
|
def delete(id: Long): IO[RepositoryError, Unit]
|
|
|
|
def listItems(menuId: Long): IO[RepositoryError, List[MenuItem]]
|
|
def insertItem(item: MenuItem): IO[RepositoryError, MenuItem]
|
|
def updateItem(item: MenuItem): IO[RepositoryError, MenuItem]
|
|
def deleteItem(id: Long): IO[RepositoryError, Unit]
|
|
def reorderItems(menuId: Long, itemOrders: List[(Long, Int)]): IO[RepositoryError, Unit]
|
|
```
|
|
|
|
**MenuService.scala:**
|
|
Service with menu resolution:
|
|
```scala
|
|
trait MenuService:
|
|
def getMenu(code: String): IO[MenuError, Option[Menu]]
|
|
def getResolved(code: String, currentUrl: String): IO[MenuError, Option[ResolvedMenu]]
|
|
def listMenus: IO[MenuError, List[Menu]]
|
|
def createMenu(code: String, name: String): IO[MenuError, Menu]
|
|
def updateMenu(id: Long, name: String): IO[MenuError, Menu]
|
|
def deleteMenu(id: Long): IO[MenuError, Unit]
|
|
|
|
def addItem(menuId: Long, item: MenuItem): IO[MenuError, MenuItem]
|
|
def updateItem(item: MenuItem): IO[MenuError, MenuItem]
|
|
def removeItem(id: Long): IO[MenuError, Unit]
|
|
def reorderItems(menuId: Long, itemOrders: List[(Long, Int)]): IO[MenuError, Unit]
|
|
|
|
object MenuService:
|
|
def live: ZLayer[MenuRepository & MenuItemTypeRegistry & CmsRouter, Nothing, MenuService] =
|
|
ZLayer.fromFunction { (repo: MenuRepository, typeRegistry: MenuItemTypeRegistry, cmsRouter: CmsRouter) =>
|
|
new MenuService:
|
|
def getResolved(code: String, currentUrl: String): IO[MenuError, Option[ResolvedMenu]] =
|
|
for
|
|
menuOpt <- repo.findByCode(code).mapError(MenuError.RepositoryFailed(_))
|
|
result <- menuOpt match
|
|
case None => ZIO.succeed(None)
|
|
case Some(menu) =>
|
|
for
|
|
items <- repo.listItems(menu.id).mapError(MenuError.RepositoryFailed(_))
|
|
context = MenuResolveContext(cmsRouter, Map.empty) // Plugin resolvers added at boot
|
|
resolved <- resolveItems(items, currentUrl, context)
|
|
yield Some(ResolvedMenu(menu.code, menu.name, resolved))
|
|
yield result
|
|
|
|
private def resolveItems(
|
|
items: List[MenuItem],
|
|
currentUrl: String,
|
|
context: MenuResolveContext
|
|
): IO[MenuError, List[ResolvedMenuItem]] =
|
|
// Build tree structure from flat list
|
|
val byParent = items.groupBy(_.parentId)
|
|
def buildTree(parentId: Option[Long]): IO[MenuError, List[ResolvedMenuItem]] =
|
|
ZIO.foreach(byParent.getOrElse(parentId, List.empty).filterNot(_.isHidden).sortBy(_.sortOrder)) { item =>
|
|
for
|
|
resolverOpt <- typeRegistry.resolve(item.itemType)
|
|
resolver <- ZIO.fromOption(resolverOpt).orElseFail(MenuError.UnknownItemType(item.itemType))
|
|
base <- resolver match
|
|
case t: MenuItemType => t.resolve(item, currentUrl, context)
|
|
case r: MenuItemResolver => r.resolve(item, currentUrl)
|
|
children <- buildTree(Some(item.id))
|
|
yield base.copy(items = children)
|
|
}
|
|
buildTree(None)
|
|
}
|
|
```
|
|
|
|
Handle circular reference protection:
|
|
- Validate parentId != id on save
|
|
- Limit max depth (e.g., 5 levels)
|
|
- Use iterative tree building if recursion depth is concern
|
|
</action>
|
|
<verify>
|
|
`./mill summercms.compile` succeeds
|
|
MenuService.getResolved builds nested menu structure
|
|
Type registry allows plugin registration
|
|
</verify>
|
|
<done>
|
|
MenuItemTypeRegistry allows plugins to register custom item types. MenuService resolves menus with nested items, URL generation, and active state detection.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Create admin routes and Pebble menu function</name>
|
|
<files>
|
|
summercms/src/api/admin/MenuRoutes.scala
|
|
summercms/src/content/cms/pebble/MenuFunction.scala
|
|
summercms/src/content/cms/pebble/CmsPebbleExtension.scala
|
|
</files>
|
|
<action>
|
|
**MenuRoutes.scala:**
|
|
Admin API for menu management:
|
|
```scala
|
|
object MenuRoutes:
|
|
val routes: Routes[MenuService, Response] =
|
|
Routes(
|
|
// List all menus
|
|
Method.GET / "admin" / "api" / "menus" -> handler { (req: Request) =>
|
|
for
|
|
service <- ZIO.service[MenuService]
|
|
menus <- service.listMenus
|
|
yield Response.json(menus.toJson)
|
|
},
|
|
|
|
// Get menu with items
|
|
Method.GET / "admin" / "api" / "menus" / string("code") -> handler { (code: String, req: Request) =>
|
|
for
|
|
service <- ZIO.service[MenuService]
|
|
menu <- service.getMenu(code)
|
|
items <- menu.fold(ZIO.succeed(List.empty[MenuItem]))(m =>
|
|
ZIO.serviceWithZIO[MenuRepository](_.listItems(m.id))
|
|
)
|
|
yield menu match
|
|
case Some(m) => Response.json(MenuWithItems(m, items).toJson)
|
|
case None => Response.status(Status.NotFound)
|
|
},
|
|
|
|
// Create menu
|
|
Method.POST / "admin" / "api" / "menus" -> handler { (req: Request) =>
|
|
for
|
|
body <- req.body.asJson[CreateMenuRequest]
|
|
service <- ZIO.service[MenuService]
|
|
menu <- service.createMenu(body.code, body.name)
|
|
yield Response.json(menu.toJson).status(Status.Created)
|
|
},
|
|
|
|
// Update menu
|
|
Method.PUT / "admin" / "api" / "menus" / long("id") -> handler { (id: Long, req: Request) =>
|
|
for
|
|
body <- req.body.asJson[UpdateMenuRequest]
|
|
service <- ZIO.service[MenuService]
|
|
menu <- service.updateMenu(id, body.name)
|
|
yield Response.json(menu.toJson)
|
|
},
|
|
|
|
// Delete menu
|
|
Method.DELETE / "admin" / "api" / "menus" / long("id") -> handler { (id: Long, req: Request) =>
|
|
for
|
|
service <- ZIO.service[MenuService]
|
|
_ <- service.deleteMenu(id)
|
|
yield Response.ok
|
|
},
|
|
|
|
// Add menu item
|
|
Method.POST / "admin" / "api" / "menus" / long("menuId") / "items" -> handler { (menuId: Long, req: Request) =>
|
|
for
|
|
body <- req.body.asJson[CreateItemRequest]
|
|
service <- ZIO.service[MenuService]
|
|
item <- service.addItem(menuId, body.toMenuItem(menuId))
|
|
yield Response.json(item.toJson).status(Status.Created)
|
|
},
|
|
|
|
// Update menu item
|
|
Method.PUT / "admin" / "api" / "menus" / "items" / long("id") -> handler { (id: Long, req: Request) =>
|
|
for
|
|
body <- req.body.asJson[UpdateItemRequest]
|
|
service <- ZIO.service[MenuService]
|
|
item <- service.updateItem(body.toMenuItem(id))
|
|
yield Response.json(item.toJson)
|
|
},
|
|
|
|
// Delete menu item
|
|
Method.DELETE / "admin" / "api" / "menus" / "items" / long("id") -> handler { (id: Long, req: Request) =>
|
|
for
|
|
service <- ZIO.service[MenuService]
|
|
_ <- service.removeItem(id)
|
|
yield Response.ok
|
|
},
|
|
|
|
// Reorder items
|
|
Method.POST / "admin" / "api" / "menus" / long("menuId") / "reorder" -> handler { (menuId: Long, req: Request) =>
|
|
for
|
|
body <- req.body.asJson[ReorderRequest]
|
|
service <- ZIO.service[MenuService]
|
|
_ <- service.reorderItems(menuId, body.itemOrders)
|
|
yield Response.ok
|
|
},
|
|
|
|
// List available item types
|
|
Method.GET / "admin" / "api" / "menus" / "item-types" -> handler { (req: Request) =>
|
|
for
|
|
registry <- ZIO.service[MenuItemTypeRegistry]
|
|
types <- registry.listTypes
|
|
yield Response.json(types.toJson)
|
|
}
|
|
)
|
|
```
|
|
|
|
**MenuFunction.scala:**
|
|
Pebble function for template access:
|
|
```scala
|
|
class MenuFunction(menuService: MenuService, runtime: Runtime[Any]) extends Function:
|
|
override def getArgumentNames: java.util.List[String] =
|
|
java.util.Arrays.asList("code")
|
|
|
|
override def execute(
|
|
args: java.util.Map[String, Object],
|
|
self: PebbleTemplate,
|
|
context: EvaluationContext,
|
|
lineNumber: Int
|
|
): Object =
|
|
val code = args.get("code").asInstanceOf[String]
|
|
val currentUrl = context.getScopeChain.get("currentUrl").asInstanceOf[String]
|
|
|
|
// Execute ZIO effect synchronously in template context
|
|
Unsafe.unsafe { implicit u =>
|
|
runtime.unsafe.run(
|
|
menuService.getResolved(code, currentUrl)
|
|
).getOrThrowFiberFailure()
|
|
} match
|
|
case Some(menu) => menu.asJava // Convert to Java collections for Pebble
|
|
case None => null
|
|
|
|
// Usage in template:
|
|
// {% set mainMenu = menu('main') %}
|
|
// {% for item in mainMenu.items %}
|
|
// <a href="{{ item.url }}" class="{{ item.isActive ? 'active' : '' }}">{{ item.title }}</a>
|
|
// {% endfor %}
|
|
```
|
|
|
|
**Update CmsPebbleExtension.scala:**
|
|
Register MenuFunction:
|
|
```scala
|
|
class CmsPebbleExtension(menuService: MenuService, runtime: Runtime[Any]) extends Extension:
|
|
override def getFunctions: java.util.Map[String, Function] =
|
|
java.util.Map.of(
|
|
"menu", new MenuFunction(menuService, runtime)
|
|
)
|
|
// ... existing token parsers
|
|
```
|
|
|
|
Note: Extension now needs MenuService injected at construction time.
|
|
</action>
|
|
<verify>
|
|
`./mill summercms.compile` succeeds
|
|
MenuRoutes provide full CRUD
|
|
menu('main') function works in templates
|
|
</verify>
|
|
<done>
|
|
Admin menu routes provide full CRUD for menus and items. Pebble menu() function allows templates to access resolved menus with active state detection. Item types extensible via registry.
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
After all tasks complete:
|
|
1. `./mill summercms.compile` - all code compiles
|
|
2. Migration V012 ready for Flyway
|
|
3. Test menu CRUD:
|
|
- Create menu with code "test"
|
|
- Add URL item, CMS page item
|
|
- Nest items under parent
|
|
- Reorder items
|
|
4. Test menu resolution:
|
|
- GET /admin/api/menus/main returns menu with items
|
|
- Items have correct URLs and active state
|
|
5. Test Pebble function:
|
|
- Template with `{% set nav = menu('main') %}` resolves menu
|
|
- Loop over items renders navigation
|
|
6. Test extensibility:
|
|
- Plugin can register custom item type
|
|
- Custom type appears in item-types list
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Menus created with unique codes for theme reference
|
|
- Menu items support URL, CMS page, and extensible plugin types
|
|
- Nested items maintain parent-child hierarchy
|
|
- Resolved menu includes active state for current URL
|
|
- Plugins can register custom menu item types via registry
|
|
- Templates access menus via menu('code') Pebble function
|
|
- Admin API provides full menu management
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/09-content-management/09-05-SUMMARY.md`
|
|
</output>
|