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
This commit is contained in:
524
.planning/phases/09-content-management/09-05-PLAN.md
Normal file
524
.planning/phases/09-content-management/09-05-PLAN.md
Normal file
@@ -0,0 +1,524 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user