Files
summercms-initial-research/.planning/phases/09-content-management/09-RESEARCH.md
Jakub Zych a12cde5c0c docs(09): research content management domain
Phase 9: Content Management
- Standard stack: Scrimage for images, ZIO S3 for storage, CodeMirror 6 for editor
- CMS page patterns from WinterCMS with Pebble templates
- Media library with storage abstraction (local/S3)
- Content revision history with document versioning pattern
- Navigation menu system with polymorphic item types
- Hot reload via WatchService + Pebble cache invalidation
2026-02-05 15:24:50 +01:00

41 KiB

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

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

{
  "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

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:

// 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:

{# 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>
// 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:

{# 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>
// 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:

// 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:

// 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:

// 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:

// 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:

// 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:

// 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

// 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

// 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

// 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 - Image processing, resize/crop
  • ZIO S3 - Version 0.4.2.1, S3 operations
  • Pebble Templates - Custom tags, extends, include
  • Pebble Extending Guide - TokenParser, custom functions
  • CodeMirror 6 - Editor setup, extensions
  • 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)

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)