# 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' %}
// <h1>{{ post.title }}</h1>

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 #}
<!DOCTYPE html>
<html lang="{{ locale }}">
<head>
  <meta charset="UTF-8">
  <title>{{ page.title }} | {{ theme.name }}</title>

  {% placeholder 'styles' %}
    {# Default styles if page doesn't define any #}
    {{ assetLink('css/app.css') }}
  {% endplaceholder %}
</head>
<body>
  {% partial 'header' %}

  <main class="container">
    {% page %}
  </main>

  {% partial 'footer' %}

  {% placeholder 'scripts' %}
    {{ assetScript('js/app.js') }}
  {% endplaceholder %}
</body>
</html>
```

```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]
==
<section class="hero">
  <h1>Welcome to Our Blog</h1>
</section>

<section class="posts">
  {% component 'posts' %}
</section>

<section class="newsletter">
  {% component 'newsSubscribe' form_class="newsletter-form" %}
</section>
```

```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)
