- Plan 02: Added key_link clarifying CmsPageService.render populates PageRenderContext.components before template rendering. Updated Task 2/3 actions to emphasize component initialization flow. - Plan 03: Split into 03 (storage + library core) and 03b (image processing + routes) to reduce scope from 9 files to 7+4 files. Estimated context reduced from ~65% to ~45% each. - Plan 03b: New plan for ImageProcessor and MediaRoutes. Added specific curl command with -F flags and expected JSON response. - Plan 05: Added plugin integration test (MenuPluginIntegrationSpec) demonstrating custom menu item type registration, resolution, and template rendering per CONT-09 requirement. - Plan 06: Reframed must_haves truths from implementation details to user-observable outcomes (e.g., 'Developer edits template file, browser refresh shows changes without server restart') - Roadmap: Updated Phase 9 from 6 to 7 plans.
21 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 09-content-management | 05 | execute | 2 |
|
|
true |
|
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, plugin integration test.
<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_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 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:
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:
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');
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:
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.compilesucceeds 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.
// Get menu with items
Method.GET / "admin" / "api" / "menus" / string("code") -> handler { ... },
// Create menu
Method.POST / "admin" / "api" / "menus" -> handler { ... },
// Update menu
Method.PUT / "admin" / "api" / "menus" / long("id") -> handler { ... },
// Delete menu
Method.DELETE / "admin" / "api" / "menus" / long("id") -> handler { ... },
// Add menu item
Method.POST / "admin" / "api" / "menus" / long("menuId") / "items" -> handler { ... },
// Update menu item
Method.PUT / "admin" / "api" / "menus" / "items" / long("id") -> handler { ... },
// Delete menu item
Method.DELETE / "admin" / "api" / "menus" / "items" / long("id") -> handler { ... },
// Reorder items
Method.POST / "admin" / "api" / "menus" / long("menuId") / "reorder" -> handler { ... },
// List available item types (includes plugin-registered 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 (constructor now needs MenuService).
MenuPluginIntegrationSpec.scala: Test demonstrating plugin integration (CONT-09 requirement):
class MenuPluginIntegrationSpec extends ZIOSpecDefault:
// Example plugin-provided menu item type: "blog-category"
// Resolves blog category IDs to category pages
class BlogCategoryResolver(blogService: BlogService) extends MenuItemResolver:
def resolve(item: MenuItem, currentUrl: String): IO[MenuError, ResolvedMenuItem] =
for
categoryId <- ZIO.fromOption(item.reference.flatMap(_.toLongOption))
.orElseFail(MenuError.InvalidReference(item.id, "Expected category ID"))
category <- blogService.getCategory(categoryId)
.someOrFail(MenuError.ReferenceNotFound(item.id, s"Category $categoryId"))
url = s"/blog/category/${category.slug}"
yield ResolvedMenuItem(
title = item.title.getOrElse(category.name),
url = url,
isActive = currentUrl == url || currentUrl.startsWith(s"$url/"),
cssClass = item.cssClass,
items = List.empty
)
def spec = suite("MenuPluginIntegration")(
test("plugin can register custom menu item type") {
for
registry <- ZIO.service[MenuItemTypeRegistry]
blogService = TestBlogService()
resolver = new BlogCategoryResolver(blogService)
// Plugin registers its type at boot
_ <- registry.register("blog-category", resolver)
// Verify type is registered
types <- registry.listTypes
_ <- assertTrue(types.contains("blog-category"))
// Verify type appears in admin API
// GET /admin/api/menus/item-types should include "blog-category"
yield assertTrue(types.contains("url"), types.contains("cms-page"), types.contains("blog-category"))
},
test("plugin-registered type resolves correctly in menu") {
for
registry <- ZIO.service[MenuItemTypeRegistry]
menuService <- ZIO.service[MenuService]
blogService = TestBlogService.withCategory(1L, "Tech", "tech")
resolver = new BlogCategoryResolver(blogService)
_ <- registry.register("blog-category", resolver)
// Create menu with plugin item type
menu <- menuService.createMenu("test-menu", "Test Menu")
_ <- menuService.addItem(menu.id, MenuItem(
id = 0,
menuId = menu.id,
parentId = None,
itemType = "blog-category", // Plugin-provided type
title = None, // Will inherit from category
reference = Some("1"), // Category ID
cssClass = None,
nestDepth = 0,
sortOrder = 0,
isHidden = false
))
// Resolve menu - plugin type should work
resolved <- menuService.getResolved("test-menu", "/other")
yield assertTrue(
resolved.isDefined,
resolved.get.items.head.title == "Tech",
resolved.get.items.head.url == "/blog/category/tech",
resolved.get.items.head.isActive == false
)
},
test("plugin type renders correctly in Pebble template") {
for
registry <- ZIO.service[MenuItemTypeRegistry]
menuService <- ZIO.service[MenuService]
blogService = TestBlogService.withCategory(1L, "Tech", "tech")
_ <- registry.register("blog-category", new BlogCategoryResolver(blogService))
// Setup menu with plugin item
menu <- menuService.createMenu("nav", "Navigation")
_ <- menuService.addItem(menu.id, MenuItem(/* blog-category type */))
// Render template using menu
template = """
{% set nav = menu('nav') %}
{% for item in nav.items %}
<a href="{{ item.url }}">{{ item.title }}</a>
{% endfor %}
"""
rendered <- renderPebbleTemplate(template, Map("currentUrl" -> "/"))
yield assertTrue(
rendered.contains("""<a href="/blog/category/tech">Tech</a>""")
)
}
).provide(
MenuItemTypeRegistry.live,
MenuService.live,
MenuRepository.live,
// ... test dependencies
)
<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
- Plugin-registered types appear in item-types API
- Plugin types resolve to valid ResolvedMenuItem with correct URLs
- Plugin types render correctly in Pebble templates
- Templates access menus via menu('code') Pebble function
- Admin API provides full menu management
- Integration test proves end-to-end plugin type functionality </success_criteria>