diff --git a/.planning/phases/09-content-management/09-RESEARCH.md b/.planning/phases/09-content-management/09-RESEARCH.md
new file mode 100644
index 0000000..266bb4d
--- /dev/null
+++ b/.planning/phases/09-content-management/09-RESEARCH.md
@@ -0,0 +1,1112 @@
+# Phase 9: Content Management - Research
+
+**Researched:** 2026-02-05
+**Domain:** CMS pages, layouts, media library, navigation menus, content versioning, hot reload
+**Confidence:** HIGH
+
+## Summary
+
+This phase implements the core content management experience for SummerCMS, including CMS pages with URL routing, layouts with placeholders, component embedding, media library with file uploads and image manipulation, content draft/published states, revision history, navigation menus with multiple item types, and hot reload for development.
+
+The research builds on established patterns from Phase 4 (Theme Engine with Pebble templates) and Phase 7 (Admin Forms & Lists with circe-yaml and ScalaTags). Key additions are: **Scrimage 4.1.0** for image resize/crop operations, **ZIO S3 0.4.2.1** for cloud storage abstraction, **CodeMirror 6** with `@ssddanbrown/codemirror-lang-twig` for the code editor, and a custom classloader-based hot reload mechanism integrated with Mill's --watch mode.
+
+The content model follows WinterCMS patterns: CMS pages as compound objects (INI settings + Twig markup), layouts defining page structure with `{% page %}` placeholder, and components embedded via `{% component %}` tags. Revisions use a separate history table with version numbers, following the document versioning pattern.
+
+**Primary recommendation:** Store CMS pages/layouts as files in the theme directory (not database) for version control compatibility, parse with Pebble at runtime. Use database for media library metadata, content states, and revision history. Implement storage abstraction layer supporting local filesystem and S3-compatible backends.
+
+## Standard Stack
+
+The established libraries/tools for this domain:
+
+### Core
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| Pebble | 4.1.1 | Twig-compatible templates | Already in stack from Phase 4, handles page/layout rendering |
+| pebble-scala | 1.0.2 | Scala Pebble wrapper | Native Scala collection support |
+| Scrimage | 4.1.0 | Image resize/crop | JVM-native, immutable, functional API, production-proven |
+| ZIO S3 | 0.4.2.1 | S3 storage backend | Official ZIO library, thin async wrapper |
+| circe-yaml | 1.0.0 | Page front matter parsing | Already in stack from Phase 7 |
+| CodeMirror | 6.x | Code editor | Industry standard, extensible, Twig support available |
+
+### Supporting
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| codemirror-lang-twig | latest | Twig syntax highlighting | Code editor for page/layout editing |
+| java.nio.file.WatchService | JDK 21 | File watching | Hot reload file change detection |
+| ZCaffeine | 0.9.10 | Template caching | Already in stack from Phase 4 |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| Scrimage | imgscalr | imgscalr is simpler but Scrimage has better Scala integration |
+| ZIO S3 | aws-scala-s3 | ZIO S3 is official ZIO library with better effect integration |
+| File-based pages | Database-stored pages | Files enable git versioning; database harder to track changes |
+| CodeMirror 6 | Monaco Editor | Monaco heavier, CodeMirror more lightweight for CMS |
+
+**Installation (Mill build.mill):**
+```scala
+def mvnDeps = Seq(
+ // Existing deps from Phase 1-8...
+
+ // Image processing
+ mvn"com.sksamuel.scrimage::scrimage-core:4.1.0",
+ mvn"com.sksamuel.scrimage::scrimage-filters:4.1.0",
+
+ // S3 storage backend
+ mvn"dev.zio::zio-s3:0.4.2.1"
+)
+```
+
+**Frontend (package.json):**
+```json
+{
+ "dependencies": {
+ "codemirror": "^6.0.0",
+ "@codemirror/lang-html": "^6.0.0",
+ "@codemirror/lang-css": "^6.0.0",
+ "@codemirror/lang-javascript": "^6.0.0",
+ "@ssddanbrown/codemirror-lang-twig": "^1.0.0"
+ }
+}
+```
+
+## Architecture Patterns
+
+### Recommended Project Structure
+```
+summercms/
+├── content/
+│ ├── cms/
+│ │ ├── CmsPage.scala # Page model
+│ │ ├── CmsLayout.scala # Layout model
+│ │ ├── CmsPartial.scala # Partial model
+│ │ ├── CmsRouter.scala # URL-to-page routing
+│ │ ├── ContentState.scala # Draft/Published enum
+│ │ └── ContentService.scala # Content CRUD operations
+│ ├── media/
+│ │ ├── MediaLibrary.scala # Media library service
+│ │ ├── MediaItem.scala # Media file model
+│ │ ├── ImageProcessor.scala # Resize/crop operations
+│ │ ├── StorageBackend.scala # Storage abstraction trait
+│ │ ├── LocalStorage.scala # Local filesystem backend
+│ │ └── S3Storage.scala # S3 backend
+│ ├── revision/
+│ │ ├── Revision.scala # Revision model
+│ │ └── RevisionService.scala # Revision tracking
+│ └── menu/
+│ ├── Menu.scala # Menu model
+│ ├── MenuItem.scala # Menu item model (polymorphic)
+│ ├── MenuItemType.scala # Item type registry
+│ └── MenuService.scala # Menu operations
+├── hot/
+│ ├── FileWatcher.scala # File change detection
+│ ├── HotReloadService.scala # Reload orchestration
+│ └── TemplateInvalidator.scala # Pebble cache invalidation
+```
+
+### Pattern 1: CMS Page with Front Matter
+**What:** Pages stored as `.htm` files with INI-style front matter and Twig markup
+**When to use:** All CMS pages
+**Example:**
+```scala
+// Source: WinterCMS CmsCompoundObject pattern
+case class CmsPageConfig(
+ url: String, // URL pattern: /blog/:slug
+ layout: Option[String] = None, // Layout name: "default"
+ title: Option[String] = None,
+ description: Option[String] = None,
+ isHidden: Boolean = false,
+ state: ContentState = ContentState.Draft,
+ components: Map[String, ComponentConfig] = Map.empty
+)
+
+case class CmsPage(
+ fileName: String, // pages/blog/post.htm
+ config: CmsPageConfig,
+ markup: String, // Twig template content
+ code: Option[String] = None, // Optional PHP/Scala code section
+ theme: Theme,
+ mtime: Instant
+)
+
+// File format (pages/blog/post.htm):
+// [section header]
+// url = "/blog/:slug"
+// layout = "default"
+// title = "Blog Post"
+//
+// [blogPost]
+// paramName = "slug"
+// ==
+// {% component 'blogPost' %}
+//
{{ post.title }}
+
+object CmsPage:
+ def parse(path: Path, theme: Theme): IO[ParseError, CmsPage] =
+ for
+ content <- ZIO.attemptBlocking(Files.readString(path))
+ .mapError(e => ParseError.FileRead(path, e))
+ sections <- parseCompoundFile(content)
+ config <- parseConfig(sections.settings)
+ mtime <- ZIO.attemptBlocking(Files.getLastModifiedTime(path).toInstant)
+ .mapError(e => ParseError.FileRead(path, e))
+ yield CmsPage(
+ fileName = theme.relativePath(path),
+ config = config,
+ markup = sections.markup,
+ code = sections.code,
+ theme = theme,
+ mtime = mtime
+ )
+
+ private def parseCompoundFile(content: String): IO[ParseError, CompoundSections] =
+ // WinterCMS format: settings section, then ==, then markup
+ val pattern = """(?s)(.*?)^==\s*$(.*?)(?:^==\s*$(.*?))?""".r
+ content match
+ case pattern(settings, markup, code) =>
+ ZIO.succeed(CompoundSections(
+ settings = settings.trim,
+ markup = markup.trim,
+ code = Option(code).map(_.trim).filter(_.nonEmpty)
+ ))
+ case _ =>
+ ZIO.succeed(CompoundSections(
+ settings = "",
+ markup = content.trim,
+ code = None
+ ))
+```
+
+### Pattern 2: Layout with Placeholders
+**What:** Layouts define page structure with `{% page %}` and named placeholders
+**When to use:** All layouts, per CONTEXT.md decision: flat layouts only (no inheritance)
+**Example:**
+```html
+{# themes/my-theme/layouts/default.htm #}
+
+
+
+
+ {{ page.title }} | {{ theme.name }}
+
+ {% placeholder 'styles' %}
+ {# Default styles if page doesn't define any #}
+ {{ assetLink('css/app.css') }}
+ {% endplaceholder %}
+
+
+ {% partial 'header' %}
+
+
+ {% page %}
+
+
+ {% partial 'footer' %}
+
+ {% placeholder 'scripts' %}
+ {{ assetScript('js/app.js') }}
+ {% endplaceholder %}
+
+
+```
+
+```scala
+// Source: CONTEXT.md - flat layouts with defaults
+// Placeholder implementation via custom Pebble tag
+class PlaceholderTag extends TokenParser:
+ override def getTag = "placeholder"
+
+ override def parse(token: Token, parser: Parser): RenderableNode =
+ val stream = parser.getStream
+ stream.next() // skip "placeholder"
+
+ val name = parser.getExpressionParser.parseExpression()
+ stream.expect(Token.Type.EXECUTE_END)
+
+ // Check if has default content (until endplaceholder)
+ val defaultBody = parser.subparse(token => {
+ token.test(Token.Type.NAME, "endplaceholder")
+ })
+ stream.next() // skip "endplaceholder"
+ stream.expect(Token.Type.EXECUTE_END)
+
+ new PlaceholderNode(token.getLineNumber, name, defaultBody)
+
+class PlaceholderNode(
+ lineNumber: Int,
+ name: Expression[?],
+ defaultBody: Body
+) extends RenderableNode:
+ override def render(self: PebbleTemplateImpl, writer: Writer, context: EvaluationContext): Unit =
+ val placeholderName = name.evaluate(self, context).toString
+ val pageContext = context.getScopeChain.get("__pageContext__")
+ .asInstanceOf[PageRenderContext]
+
+ // Check if page put content into this placeholder
+ pageContext.placeholderContent.get(placeholderName) match
+ case Some(content) =>
+ writer.write(content)
+ case None =>
+ // Render default content
+ defaultBody.render(self, writer, context)
+```
+
+### Pattern 3: Component Embedding in CMS Pages
+**What:** Embed components via Twig tag syntax, per CONTEXT.md decision
+**When to use:** Pages that need dynamic functionality
+**Example:**
+```html
+{# pages/blog/index.htm #}
+url = "/"
+layout = "default"
+title = "Home"
+
+[blogPosts posts]
+postsPerPage = 5
+sortOrder = "published_at desc"
+
+[newsSubscribe]
+==
+
+
+
+ {% component 'posts' %}
+
+
+
+ {% component 'newsSubscribe' form_class="newsletter-form" %}
+
+```
+
+```scala
+// Source: Phase 3 component embedding + CONTEXT.md syntax
+// Custom Pebble tag for component rendering
+class ComponentTag extends TokenParser:
+ override def getTag = "component"
+
+ override def parse(token: Token, parser: Parser): RenderableNode =
+ val stream = parser.getStream
+ stream.next() // skip "component"
+
+ // Parse component alias (required)
+ val alias = parser.getExpressionParser.parseExpression()
+
+ // Parse optional properties
+ val properties = mutable.Map[String, Expression[?]]()
+ while !stream.current.test(Token.Type.EXECUTE_END) do
+ val propName = stream.expect(Token.Type.NAME).getValue
+ stream.expect(Token.Type.PUNCTUATION, "=")
+ val propValue = parser.getExpressionParser.parseExpression()
+ properties(propName) = propValue
+
+ stream.expect(Token.Type.EXECUTE_END)
+
+ new ComponentNode(token.getLineNumber, alias, properties.toMap)
+
+class ComponentNode(
+ lineNumber: Int,
+ alias: Expression[?],
+ properties: Map[String, Expression[?]]
+) extends RenderableNode:
+ override def render(self: PebbleTemplateImpl, writer: Writer, context: EvaluationContext): Unit =
+ val componentAlias = alias.evaluate(self, context).toString
+ val pageContext = context.getScopeChain.get("__pageContext__")
+ .asInstanceOf[PageRenderContext]
+
+ // Resolve component from page's component definitions
+ val component = pageContext.components.get(componentAlias)
+ .getOrElse(throw new PebbleException(
+ s"Component '$componentAlias' not found on page",
+ lineNumber
+ ))
+
+ // Override properties from tag
+ val overrides = properties.map { case (k, v) =>
+ k -> v.evaluate(self, context)
+ }
+
+ // Render component
+ val html = pageContext.runtime.unsafeRun(
+ component.render(overrides)
+ )
+ writer.write(html.value)
+```
+
+### Pattern 4: Media Library with Storage Abstraction
+**What:** File uploads with pluggable storage backends (local, S3, GCS)
+**When to use:** Media library per CONTEXT.md decision
+**Example:**
+```scala
+// Source: WinterCMS MediaLibrary + ZIO S3
+trait StorageBackend:
+ def put(path: String, content: ZStream[Any, Throwable, Byte], contentType: String): IO[StorageError, Unit]
+ def get(path: String): IO[StorageError, ZStream[Any, Throwable, Byte]]
+ def delete(path: String): IO[StorageError, Unit]
+ def exists(path: String): IO[StorageError, Boolean]
+ def url(path: String): IO[StorageError, String]
+ def list(folder: String): IO[StorageError, List[MediaItem]]
+
+// Local filesystem implementation
+class LocalStorage(basePath: Path, publicUrl: String) extends StorageBackend:
+ def put(path: String, content: ZStream[Any, Throwable, Byte], contentType: String): IO[StorageError, Unit] =
+ val fullPath = basePath.resolve(path)
+ for
+ _ <- ZIO.attemptBlocking(Files.createDirectories(fullPath.getParent))
+ _ <- content.run(ZSink.fromFile(fullPath))
+ yield ()
+
+ def url(path: String): IO[StorageError, String] =
+ ZIO.succeed(s"$publicUrl/$path")
+
+// S3 implementation
+class S3Storage(bucket: String, region: String, credentials: AwsCredentials) extends StorageBackend:
+ private val s3Layer = live(region, credentials)
+
+ def put(path: String, content: ZStream[Any, Throwable, Byte], contentType: String): IO[StorageError, Unit] =
+ for
+ bytes <- content.runCollect
+ _ <- putObject(
+ bucket,
+ path,
+ bytes.length.toLong,
+ ZStream.fromChunk(bytes),
+ UploadOptions.fromContentType(contentType)
+ ).provideLayer(s3Layer)
+ yield ()
+
+ def url(path: String): IO[StorageError, String] =
+ ZIO.succeed(s"https://$bucket.s3.$region.amazonaws.com/$path")
+
+// Media library service
+trait MediaLibrary:
+ def upload(folder: String, fileName: String, content: ZStream[Any, Throwable, Byte], contentType: String): IO[MediaError, MediaItem]
+ def list(folder: String, filter: Option[MediaFilter] = None): IO[MediaError, List[MediaItem]]
+ def delete(paths: List[String]): IO[MediaError, Unit]
+ def createFolder(path: String): IO[MediaError, Unit]
+ def moveFile(oldPath: String, newPath: String): IO[MediaError, Unit]
+
+case class MediaItem(
+ path: String,
+ publicUrl: String,
+ size: Long,
+ lastModified: Instant,
+ itemType: MediaItemType,
+ mimeType: Option[String]
+)
+
+enum MediaItemType:
+ case File, Folder, Image, Video, Audio, Document
+
+enum MediaFilter:
+ case Images, Videos, Audio, Documents
+```
+
+### Pattern 5: Image Processing with Scrimage
+**What:** Resize and crop images, per CONTEXT.md: crop and resize only
+**When to use:** Media library image manipulation
+**Example:**
+```scala
+// Source: Scrimage documentation
+import com.sksamuel.scrimage.*
+import com.sksamuel.scrimage.nio.*
+
+trait ImageProcessor:
+ def resize(input: Array[Byte], width: Int, height: Int): IO[ImageError, Array[Byte]]
+ def crop(input: Array[Byte], x: Int, y: Int, width: Int, height: Int): IO[ImageError, Array[Byte]]
+ def generateThumbnail(input: Array[Byte], maxWidth: Int, maxHeight: Int): IO[ImageError, Array[Byte]]
+
+object ImageProcessor:
+ val live: ULayer[ImageProcessor] = ZLayer.succeed {
+ new ImageProcessor:
+ def resize(input: Array[Byte], width: Int, height: Int): IO[ImageError, Array[Byte]] =
+ ZIO.attemptBlocking {
+ ImmutableImage.loader()
+ .fromBytes(input)
+ .fit(width, height) // Fit maintains aspect ratio
+ .bytes(JpegWriter.Default)
+ }.mapError(e => ImageError.ProcessingFailed(e))
+
+ def crop(input: Array[Byte], x: Int, y: Int, width: Int, height: Int): IO[ImageError, Array[Byte]] =
+ ZIO.attemptBlocking {
+ ImmutableImage.loader()
+ .fromBytes(input)
+ .subimage(x, y, width, height)
+ .bytes(JpegWriter.Default)
+ }.mapError(e => ImageError.ProcessingFailed(e))
+
+ def generateThumbnail(input: Array[Byte], maxWidth: Int, maxHeight: Int): IO[ImageError, Array[Byte]] =
+ ZIO.attemptBlocking {
+ ImmutableImage.loader()
+ .fromBytes(input)
+ .bound(maxWidth, maxHeight) // Scale to fit within bounds
+ .bytes(JpegWriter.Default)
+ }.mapError(e => ImageError.ProcessingFailed(e))
+ }
+```
+
+### Pattern 6: Content State and Revision History
+**What:** Draft/Published states with revision tracking
+**When to use:** All content per CONTEXT.md: two states only
+**Example:**
+```scala
+// Source: CONTEXT.md decisions + document versioning pattern
+enum ContentState:
+ case Draft, Published
+
+case class ContentRevision(
+ id: Long,
+ contentType: String, // "page", "partial", "layout"
+ contentKey: String, // e.g., "pages/blog/post.htm"
+ version: Int,
+ content: String, // Full content snapshot
+ authorId: Long,
+ createdAt: Instant,
+ description: Option[String] // Optional change description
+)
+
+// Revision service - per CONTEXT.md: Claude's discretion on auto-save vs manual
+trait RevisionService:
+ def createRevision(
+ contentType: String,
+ contentKey: String,
+ content: String,
+ authorId: Long,
+ description: Option[String] = None
+ ): IO[RevisionError, ContentRevision]
+
+ def listRevisions(
+ contentType: String,
+ contentKey: String,
+ limit: Int = 50
+ ): IO[RevisionError, List[ContentRevision]]
+
+ def getRevision(id: Long): IO[RevisionError, Option[ContentRevision]]
+
+ def rollback(id: Long, authorId: Long): IO[RevisionError, ContentRevision]
+
+object RevisionService:
+ // Implementation with configurable retention policy
+ def live(retentionDays: Int = 90): ZLayer[RevisionRepository, Nothing, RevisionService] =
+ ZLayer.fromFunction { (repo: RevisionRepository) =>
+ new RevisionService:
+ def createRevision(
+ contentType: String,
+ contentKey: String,
+ content: String,
+ authorId: Long,
+ description: Option[String]
+ ): IO[RevisionError, ContentRevision] =
+ for
+ currentVersion <- repo.getLatestVersion(contentType, contentKey)
+ revision = ContentRevision(
+ id = 0, // Generated by DB
+ contentType = contentType,
+ contentKey = contentKey,
+ version = currentVersion + 1,
+ content = content,
+ authorId = authorId,
+ createdAt = Instant.now,
+ description = description
+ )
+ saved <- repo.insert(revision)
+ // Cleanup old revisions beyond retention
+ _ <- repo.deleteOlderThan(
+ contentType,
+ contentKey,
+ Instant.now.minus(retentionDays, ChronoUnit.DAYS)
+ )
+ yield saved
+ }
+
+// Database schema
+// CREATE TABLE content_revisions (
+// id BIGSERIAL PRIMARY KEY,
+// content_type VARCHAR(50) NOT NULL,
+// content_key VARCHAR(255) NOT NULL,
+// version INT NOT NULL,
+// content TEXT NOT NULL,
+// author_id BIGINT NOT NULL REFERENCES backend_users(id),
+// created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+// description TEXT,
+// UNIQUE(content_type, content_key, version)
+// );
+// CREATE INDEX idx_revisions_lookup ON content_revisions(content_type, content_key, version DESC);
+```
+
+### Pattern 7: Navigation Menus with Polymorphic Items
+**What:** Menu system with extensible item types (URL, page reference, plugin-generated)
+**When to use:** Navigation management per CONT-08, CONT-09
+**Example:**
+```scala
+// Source: WinterCMS menu system + CONTEXT.md extensibility
+case class Menu(
+ id: Long,
+ code: String, // Unique identifier for theme reference
+ name: String,
+ items: List[MenuItem]
+)
+
+sealed trait MenuItemType:
+ def resolve(item: MenuItem, currentUrl: String, theme: Theme): IO[MenuError, ResolvedMenuItem]
+
+object MenuItemType:
+ case object Url extends MenuItemType:
+ def resolve(item: MenuItem, currentUrl: String, theme: Theme): IO[MenuError, ResolvedMenuItem] =
+ ZIO.succeed(ResolvedMenuItem(
+ title = item.title,
+ url = item.reference.getOrElse("#"),
+ isActive = item.reference.contains(currentUrl),
+ items = List.empty
+ ))
+
+ case object CmsPage extends MenuItemType:
+ def resolve(item: MenuItem, currentUrl: String, theme: Theme): IO[MenuError, ResolvedMenuItem] =
+ for
+ page <- CmsPage.loadCached(theme, item.reference.getOrElse(""))
+ .someOrFail(MenuError.PageNotFound(item.reference))
+ url <- CmsRouter.pageUrl(page, Map.empty)
+ yield ResolvedMenuItem(
+ title = item.title.orElse(page.config.title).getOrElse(page.fileName),
+ url = url,
+ isActive = url == currentUrl,
+ items = List.empty
+ )
+
+ // Plugin-generated items (e.g., blog categories)
+ case class PluginGenerated(
+ itemType: String, // e.g., "blog-category"
+ resolver: MenuItemResolver // Plugin-provided resolver
+ ) extends MenuItemType:
+ def resolve(item: MenuItem, currentUrl: String, theme: Theme): IO[MenuError, ResolvedMenuItem] =
+ resolver.resolve(item, currentUrl, theme)
+
+// Menu item type registry - per CONTEXT.md: Claude's discretion on extensibility
+trait MenuItemTypeRegistry:
+ def register(typeName: String, itemType: MenuItemType): UIO[Unit]
+ def resolve(typeName: String): UIO[Option[MenuItemType]]
+
+case class MenuItem(
+ id: Long,
+ menuId: Long,
+ parentId: Option[Long],
+ itemType: String, // "url", "cms-page", "blog-category", etc.
+ title: Option[String],
+ reference: Option[String], // URL or page reference or plugin key
+ nestDepth: Int,
+ sortOrder: Int
+)
+
+case class ResolvedMenuItem(
+ title: String,
+ url: String,
+ isActive: Boolean,
+ items: List[ResolvedMenuItem]
+)
+
+// Database schema
+// 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(255),
+// nest_depth INT NOT NULL DEFAULT 0,
+// sort_order INT NOT NULL DEFAULT 0
+// );
+// CREATE INDEX idx_menu_items_menu ON menu_items(menu_id, sort_order);
+```
+
+### Pattern 8: Hot Reload for Development
+**What:** File watching with template cache invalidation, per DIFF-02
+**When to use:** Development mode only
+**Example:**
+```scala
+// Source: JVM hot reload patterns + Mill --watch
+import java.nio.file.{WatchService, WatchKey, WatchEvent, StandardWatchEventKinds}
+
+trait HotReloadService:
+ def start: IO[HotReloadError, Unit]
+ def stop: IO[HotReloadError, Unit]
+
+object HotReloadService:
+ def live(
+ watchPaths: List[Path],
+ pebbleEngine: PebbleEngine,
+ onReload: () => UIO[Unit]
+ ): ZLayer[Any, Nothing, HotReloadService] =
+ ZLayer.fromZIO {
+ for
+ watchService <- ZIO.attemptBlocking(FileSystems.getDefault.newWatchService())
+ running <- Ref.make(false)
+ yield new HotReloadService:
+ def start: IO[HotReloadError, Unit] =
+ for
+ _ <- running.set(true)
+ // Register all paths recursively
+ _ <- ZIO.foreach(watchPaths) { path =>
+ registerRecursive(path, watchService)
+ }
+ // Start watching in background
+ _ <- watchLoop(watchService, pebbleEngine, onReload)
+ .fork
+ yield ()
+
+ def stop: IO[HotReloadError, Unit] =
+ for
+ _ <- running.set(false)
+ _ <- ZIO.attemptBlocking(watchService.close())
+ yield ()
+
+ private def watchLoop(
+ watcher: WatchService,
+ engine: PebbleEngine,
+ onReload: () => UIO[Unit]
+ ): IO[HotReloadError, Unit] =
+ running.get.flatMap { isRunning =>
+ if !isRunning then ZIO.unit
+ else
+ for
+ key <- ZIO.attemptBlocking(watcher.take())
+ events = key.pollEvents().asScala.toList
+ _ <- ZIO.foreach(events) { event =>
+ val kind = event.kind()
+ if kind != StandardWatchEventKinds.OVERFLOW then
+ val path = event.context().asInstanceOf[Path]
+ // Invalidate Pebble cache
+ ZIO.attemptBlocking {
+ engine.getTagCache.invalidateAll()
+ engine.getTemplateCache.invalidateAll()
+ } *> onReload()
+ else ZIO.unit
+ }
+ _ <- ZIO.attemptBlocking(key.reset())
+ _ <- watchLoop(watcher, engine, onReload)
+ yield ()
+ }
+
+ private def registerRecursive(path: Path, watcher: WatchService): UIO[Unit] =
+ ZIO.attemptBlocking {
+ Files.walkFileTree(path, new SimpleFileVisitor[Path] {
+ override def preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult = {
+ dir.register(
+ watcher,
+ StandardWatchEventKinds.ENTRY_CREATE,
+ StandardWatchEventKinds.ENTRY_MODIFY,
+ StandardWatchEventKinds.ENTRY_DELETE
+ )
+ FileVisitResult.CONTINUE
+ }
+ })
+ }.ignore
+ }
+```
+
+### Pattern 9: CMS Page URL Router
+**What:** Match URLs to pages with parameter extraction
+**When to use:** Frontend page routing
+**Example:**
+```scala
+// Source: WinterCMS Router class
+trait CmsRouter:
+ def findByUrl(url: String): IO[RouterError, Option[(CmsPage, Map[String, String])]]
+ def pageUrl(page: CmsPage, params: Map[String, String]): IO[RouterError, String]
+
+object CmsRouter:
+ // URL pattern format:
+ // /blog/:slug - required parameter
+ // /blog/:page? - optional parameter
+ // /blog/:page?1 - optional with default
+ // /blog/:id|^[0-9]+$ - with regex validation
+
+ case class Route(
+ pattern: String,
+ pageFile: String,
+ segments: List[RouteSegment]
+ )
+
+ sealed trait RouteSegment
+ object RouteSegment:
+ case class Literal(value: String) extends RouteSegment
+ case class Parameter(
+ name: String,
+ optional: Boolean,
+ default: Option[String],
+ regex: Option[String]
+ ) extends RouteSegment
+
+ def live(theme: Theme): ZLayer[Any, Nothing, CmsRouter] =
+ ZLayer.fromZIO {
+ for
+ routes <- buildRouteMap(theme)
+ cache <- Ref.make(routes)
+ yield new CmsRouter:
+ def findByUrl(url: String): IO[RouterError, Option[(CmsPage, Map[String, String])]] =
+ for
+ routeMap <- cache.get
+ result <- matchUrl(url, routeMap, theme)
+ yield result
+
+ def pageUrl(page: CmsPage, params: Map[String, String]): IO[RouterError, String] =
+ ZIO.succeed(buildUrl(page.config.url, params))
+ }
+
+ private def buildRouteMap(theme: Theme): IO[RouterError, List[Route]] =
+ for
+ pages <- theme.listPages
+ routes = pages.map { page =>
+ Route(
+ pattern = page.config.url,
+ pageFile = page.fileName,
+ segments = parsePattern(page.config.url)
+ )
+ }
+ yield routes.sortBy(r => -specificity(r.segments))
+
+ private def parsePattern(pattern: String): List[RouteSegment] =
+ pattern.split("/").filter(_.nonEmpty).map { segment =>
+ if segment.startsWith(":") then
+ val paramPart = segment.drop(1)
+ val (name, rest) = paramPart.span(c => c != '?' && c != '|')
+ val optional = rest.startsWith("?")
+ val afterOptional = if optional then rest.drop(1) else rest
+ val (default, regex) = afterOptional.span(_ != '|') match
+ case (d, r) if r.startsWith("|") => (Option(d).filter(_.nonEmpty), Some(r.drop(1)))
+ case (d, _) => (Option(d).filter(_.nonEmpty), None)
+ RouteSegment.Parameter(name, optional, default, regex)
+ else
+ RouteSegment.Literal(segment)
+ }.toList
+```
+
+### Anti-Patterns to Avoid
+- **Storing CMS pages in database:** Loses git versioning, harder to deploy
+- **Layout inheritance beyond one level:** Per CONTEXT.md: flat layouts only
+- **Processing images synchronously:** Always use ZIO.attemptBlocking for Scrimage
+- **Caching user-specific content:** Draft content shouldn't be cached
+- **Missing storage abstraction:** Always use StorageBackend trait, not direct S3/filesystem calls
+- **Auto-running hot reload in production:** Must be dev-mode only, check environment
+- **Regex validation in URLs without escaping:** Always compile and cache regex patterns
+
+## Don't Hand-Roll
+
+Problems that look simple but have existing solutions:
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Image resize/crop | Custom ImageIO code | Scrimage | Color profiles, EXIF handling, memory management |
+| Twig template parsing | Custom parser | Pebble | Complex inheritance, filters, autoescaping |
+| S3 operations | Direct AWS SDK calls | ZIO S3 | Async integration, ZIO effect composition |
+| File watching | Thread.sleep polling | WatchService | OS-level events, efficient, no busy waiting |
+| URL pattern matching | Custom regex | Adapted WinterCMS Router | Edge cases: optional params, defaults, validation |
+| Code editor | Custom textarea | CodeMirror 6 | Syntax highlighting, undo/redo, line numbers |
+| Content versioning | Simple backup files | Revision table pattern | Query history, rollback, audit trail |
+
+**Key insight:** Content management appears simple but has many edge cases: URL encoding, file permissions, image orientation, content state transitions, concurrent edits. Use proven patterns from WinterCMS adapted to ZIO.
+
+## Common Pitfalls
+
+### Pitfall 1: Image EXIF Orientation Ignored
+**What goes wrong:** Images appear rotated incorrectly after upload
+**Why it happens:** EXIF rotation metadata not applied during processing
+**How to avoid:** Use Scrimage's `ImmutableImage.loader().detectOrientation(true)`
+**Warning signs:** Portrait photos displaying sideways
+
+### Pitfall 2: File Path Traversal in Media Library
+**What goes wrong:** Attacker accesses files outside media directory
+**Why it happens:** Path like `../../../etc/passwd` not validated
+**How to avoid:** Normalize paths, validate within storage root, reject `..` segments
+**Warning signs:** Security scan failures, unexpected file access
+
+### Pitfall 3: Hot Reload Memory Leak
+**What goes wrong:** Memory usage grows over time in development
+**Why it happens:** Old classloaders not garbage collected, strong references held
+**How to avoid:** Clear all caches on reload, use weak references for listeners
+**Warning signs:** OutOfMemoryError after many reloads
+
+### Pitfall 4: Draft Content Visible to Public
+**What goes wrong:** Unpublished content appears on frontend
+**Why it happens:** Missing state check in content queries
+**How to avoid:** Always filter by state=Published for non-admin requests
+**Warning signs:** Complaints about seeing unfinished content
+
+### Pitfall 5: Menu Item Circular Reference
+**What goes wrong:** Stack overflow when resolving menu items
+**Why it happens:** Parent references create cycle
+**How to avoid:** Validate no cycles on save, use max depth limit in resolution
+**Warning signs:** Menu pages timing out, recursive resolution
+
+### Pitfall 6: Large File Upload Blocking
+**What goes wrong:** Server becomes unresponsive during large uploads
+**Why it happens:** Upload processed on main thread without streaming
+**How to avoid:** Stream uploads to storage, use chunked processing
+**Warning signs:** Timeouts during media upload, other requests blocked
+
+### Pitfall 7: Template Cache Not Invalidated on Component Change
+**What goes wrong:** Old component behavior persists after code change
+**Why it happens:** Only template files watched, not Scala component code
+**How to avoid:** Hot reload must invalidate Pebble cache AND restart component registry
+**Warning signs:** Changes don't take effect until full restart
+
+## Code Examples
+
+Verified patterns from official sources:
+
+### Complete CMS Page Service
+```scala
+// Source: WinterCMS Controller + ZIO patterns
+trait CmsPageService:
+ def load(fileName: String): IO[PageError, CmsPage]
+ def save(page: CmsPage, authorId: Long): IO[PageError, CmsPage]
+ def delete(fileName: String): IO[PageError, Unit]
+ def list: IO[PageError, List[CmsPageSummary]]
+ def render(page: CmsPage, request: Request): IO[PageError, Response]
+
+object CmsPageService:
+ val live: ZLayer[
+ ThemeService & ComponentManager & RevisionService & PebbleEngine,
+ Nothing,
+ CmsPageService
+ ] = ZLayer.fromFunction { (
+ themes: ThemeService,
+ components: ComponentManager,
+ revisions: RevisionService,
+ pebble: PebbleEngine
+ ) =>
+ new CmsPageService:
+ def load(fileName: String): IO[PageError, CmsPage] =
+ for
+ theme <- themes.getActive
+ path = theme.path.resolve(fileName)
+ page <- CmsPage.parse(path, theme)
+ yield page
+
+ def save(page: CmsPage, authorId: Long): IO[PageError, CmsPage] =
+ for
+ theme <- themes.getActive
+ path = theme.path.resolve(page.fileName)
+ content = serializePage(page)
+ // Create revision before saving
+ _ <- revisions.createRevision(
+ "page",
+ page.fileName,
+ content,
+ authorId,
+ Some("Manual save")
+ )
+ _ <- ZIO.attemptBlocking {
+ Files.writeString(path, content)
+ }
+ saved <- load(page.fileName)
+ yield saved
+
+ def render(page: CmsPage, request: Request): IO[PageError, Response] =
+ for
+ theme <- themes.getActive
+ layout <- loadLayout(page.config.layout, theme)
+ // Initialize components from page config
+ comps <- ZIO.foreach(page.config.components.toList) { case (alias, config) =>
+ components.make(config.className, alias, config.properties)
+ }
+ // Run component lifecycle
+ _ <- ZIO.foreach(comps)(_.onRun)
+ // Build render context
+ context = buildContext(page, comps, request)
+ // Render page content
+ pageHtml <- renderTemplate(pebble, page.markup, context)
+ // Render layout with page content
+ layoutContext = context + ("pageContent" -> pageHtml)
+ html <- renderTemplate(pebble, layout.markup, layoutContext)
+ yield Response.html(html)
+ }
+```
+
+### Media Upload with Image Processing
+```scala
+// Source: Scrimage docs + ZIO S3
+def uploadMedia(
+ folder: String,
+ fileName: String,
+ content: ZStream[Any, Throwable, Byte],
+ contentType: String
+): ZIO[MediaLibrary & ImageProcessor, MediaError, MediaItem] =
+ for
+ library <- ZIO.service[MediaLibrary]
+ processor <- ZIO.service[ImageProcessor]
+ bytes <- content.runCollect.map(_.toArray)
+
+ // Process image if applicable
+ processedBytes <- contentType match
+ case ct if ct.startsWith("image/") =>
+ // Generate thumbnail for sidebar
+ processor.generateThumbnail(bytes, 300, 255)
+ .as(bytes) // Keep original, thumbnail generated on demand
+ case _ =>
+ ZIO.succeed(bytes)
+
+ // Upload to storage
+ path = s"$folder/$fileName"
+ _ <- library.storage.put(
+ path,
+ ZStream.fromChunk(Chunk.fromArray(processedBytes)),
+ contentType
+ )
+
+ // Create media item record
+ url <- library.storage.url(path)
+ item = MediaItem(
+ path = path,
+ publicUrl = url,
+ size = processedBytes.length,
+ lastModified = Instant.now,
+ itemType = MediaItemType.fromContentType(contentType),
+ mimeType = Some(contentType)
+ )
+ yield item
+```
+
+### Code Editor Setup with CodeMirror 6
+```javascript
+// Source: CodeMirror 6 docs + codemirror-lang-twig
+import { EditorView, basicSetup } from "codemirror"
+import { EditorState } from "@codemirror/state"
+import { html } from "@codemirror/lang-html"
+import { twig, twigLanguage } from "@ssddanbrown/codemirror-lang-twig"
+import { StreamLanguage } from "@codemirror/language"
+
+// Create editor for CMS page editing
+function createPageEditor(container, initialContent, onChange) {
+ const state = EditorState.create({
+ doc: initialContent,
+ extensions: [
+ basicSetup,
+ // Mixed HTML + Twig highlighting
+ html(),
+ twig(),
+ // Line numbers and gutter
+ EditorView.lineWrapping,
+ // Change callback for auto-save
+ EditorView.updateListener.of((update) => {
+ if (update.docChanged) {
+ onChange(update.state.doc.toString())
+ }
+ }),
+ // Theme
+ EditorView.theme({
+ "&": { height: "400px" },
+ ".cm-scroller": { overflow: "auto" }
+ })
+ ]
+ })
+
+ return new EditorView({
+ state,
+ parent: container
+ })
+}
+
+// Usage in admin form
+const editor = createPageEditor(
+ document.getElementById('code-editor'),
+ document.getElementById('markup-input').value,
+ debounce((content) => {
+ // Auto-save draft
+ htmx.ajax('POST', '/admin/cms/page/autosave', {
+ values: { markup: content }
+ })
+ }, 1000)
+)
+```
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| Database-stored templates | File-based templates | Always | Git versioning, deployment, editor support |
+| jQuery file upload | HTMX + streaming | 2020+ | Progress feedback, chunked upload |
+| ImageMagick CLI | JVM-native Scrimage | 2018+ | No external dependency, better integration |
+| CodeMirror 5 | CodeMirror 6 | 2021+ | Modular, tree-sitter, accessibility |
+| Manual revision tables | Temporal tables (SQL:2011) | Where supported | Automatic history, simpler queries |
+| Polling for changes | WatchService | JDK 7+ | Efficient, OS-native events |
+
+**Deprecated/outdated:**
+- **Database-stored code:** Anti-pattern per REQUIREMENTS.md
+- **Nashorn JavaScript:** Removed in Java 15
+- **CodeMirror 5:** Replaced by modular CodeMirror 6
+- **Manual file polling:** WatchService is more efficient
+
+## Open Questions
+
+Things that couldn't be fully resolved:
+
+1. **Auto-save Frequency for Revisions**
+ - What we know: CONTEXT.md says Claude's discretion
+ - What's unclear: Optimal balance between history granularity and storage
+ - Recommendation: Auto-save on blur (editor loses focus) + explicit "Save" button; keep max 50 revisions per item
+
+2. **Hot Reload Scope**
+ - What we know: Templates and assets should reload; Scala code requires restart
+ - What's unclear: Can we reload component Scala code without JVM restart?
+ - Recommendation: Use classloader-based reload for components if possible; fall back to template-only reload
+
+3. **Media Library Sync Across Instances**
+ - What we know: S3 provides shared storage; local filesystem is per-instance
+ - What's unclear: How to handle metadata (database) vs files (storage) consistency
+ - Recommendation: Store all metadata in database, files in S3 for multi-instance; use database as source of truth
+
+4. **Large Image Upload Memory**
+ - What we know: Scrimage loads full image into memory
+ - What's unclear: Maximum practical image size before OOM
+ - Recommendation: Set upload limit (10MB default), stream directly to storage, process thumbnails on-demand
+
+5. **Menu Item Type Plugin Registration**
+ - What we know: Plugins can register custom menu item types
+ - What's unclear: When in boot lifecycle to register
+ - Recommendation: Register in plugin boot() after core boot complete
+
+## Sources
+
+### Primary (HIGH confidence)
+- [Scrimage Documentation](https://sksamuel.github.io/scrimage/) - Image processing, resize/crop
+- [ZIO S3](https://zio.dev/zio-s3/) - Version 0.4.2.1, S3 operations
+- [Pebble Templates](https://pebbletemplates.io/) - Custom tags, extends, include
+- [Pebble Extending Guide](https://pebbletemplates.io/wiki/guide/extending-pebble/) - TokenParser, custom functions
+- [CodeMirror 6](https://codemirror.net/) - Editor setup, extensions
+- [codemirror-lang-twig](https://github.com/ssddanbrown/codemirror-lang-twig) - Twig syntax highlighting
+- WinterCMS CmsCompoundObject.php - Page/Layout parsing pattern (local reference)
+- WinterCMS Router.php - URL routing pattern (local reference)
+- WinterCMS MediaLibrary.php - Storage abstraction (local reference)
+
+### Secondary (MEDIUM confidence)
+- [JVM Hot Reload Guide](https://foojay.io/today/hot-class-reload-in-java-a-webpack-hmr-like-experience-for-java-developers/) - Classloader patterns
+- [Live Reloading on JVM](https://seroperson.me/2025/11/28/jvm-live-reload/) - File watching, Mill integration
+- [MongoDB Document Versioning](https://www.mongodb.com/blog/post/building-with-patterns-the-document-versioning-pattern) - Revision history pattern
+- [CodeMirror Mixed Language](https://codemirror.net/examples/mixed-language/) - HTML + Twig parsing
+
+### Tertiary (LOW confidence)
+- WebSearch results on navigation menu schemas
+- General CMS architecture patterns
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH - Scrimage, ZIO S3, Pebble well-documented
+- CMS page patterns: HIGH - Based on WinterCMS reference code
+- Media library: HIGH - WinterCMS MediaLibrary adapted with ZIO S3
+- Image processing: HIGH - Scrimage documentation verified
+- Revision history: MEDIUM - Document versioning pattern adapted
+- Hot reload: MEDIUM - Multiple approaches documented, integration needs validation
+- Menu system: MEDIUM - Based on WinterCMS patterns, type extensibility needs testing
+
+**Research date:** 2026-02-05
+**Valid until:** 2026-03-05 (30 days - stable domain)