--- 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" --- 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. @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md @.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 Task 1: Create menu models and database migration 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 **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'); ``` `./mill summercms.compile` succeeds with new models Migration file syntactically valid Menu and MenuItem models with ResolvedMenuItem for frontend. MenuItemType trait for URL and CMS page types. Migration creates tables with default menus. Task 2: Implement MenuItemTypeRegistry and MenuService summercms/src/content/menu/MenuItemTypeRegistry.scala summercms/src/content/menu/MenuService.scala summercms/src/content/menu/MenuRepository.scala **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 `./mill summercms.compile` succeeds MenuService.getResolved builds nested menu structure Type registry allows plugin registration MenuItemTypeRegistry allows plugins to register custom item types. MenuService resolves menus with nested items, URL generation, and active state detection. Task 3: Create admin routes and Pebble menu function summercms/src/api/admin/MenuRoutes.scala summercms/src/content/cms/pebble/MenuFunction.scala summercms/src/content/cms/pebble/CmsPebbleExtension.scala **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 %} // {{ item.title }} // {% 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. `./mill summercms.compile` succeeds MenuRoutes provide full CRUD menu('main') function works in templates 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. 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 - 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 After completion, create `.planning/phases/09-content-management/09-05-SUMMARY.md`