Files
summercms-initial-research/.planning/phases/09-content-management/09-05-PLAN.md
Jakub Zych dca89e10cd docs(09): create phase plan
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
2026-02-05 15:33:51 +01:00

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>