Files
summercms-initial-research/.planning/phases/04-theme-engine/04-01-PLAN.md
Jakub Zych 44685de65e docs(04): create phase plan
Phase 04: Theme Engine
- 2 plans in 2 waves
- Wave 1: Theme loading and layout composition (04-01)
- Wave 2: Asset pipeline and Vue integration (04-02)
- Ready for execution
2026-02-05 14:06:11 +01:00

37 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
04-theme-engine 01 execute 1
build.mill
summercms/src/theme/ThemeConfig.scala
summercms/src/theme/PageConfig.scala
summercms/src/theme/RenderingMode.scala
summercms/src/theme/ThemeError.scala
summercms/src/theme/ThemeLoader.scala
summercms/src/theme/ThemeService.scala
summercms/src/theme/pebble/SummerThemeExtension.scala
summercms/src/theme/pebble/PartialTag.scala
summercms/src/theme/pebble/PageTag.scala
summercms/src/theme/pebble/PlaceholderTag.scala
summercms/src/theme/pebble/PutTag.scala
true
truths artifacts key_links
Developer can create a theme with theme.yaml, layouts/, pages/, and partials/ directories
Pages declare their layout in front matter YAML
Layouts use {% page %} to inject page content
{% partial 'name' %} includes partial templates with variable passing
{% placeholder 'name' %} defines named content blocks
{% put 'name' %} fills placeholder blocks from pages
Theme rendering mode is configured in theme.yaml
path provides exports
summercms/src/theme/ThemeService.scala Theme rendering service
ThemeService
path provides contains
summercms/src/theme/ThemeConfig.scala Theme metadata from theme.yaml case class ThemeConfig
path provides contains
summercms/src/theme/PageConfig.scala Page front matter parsing case class PageConfig
path provides contains
summercms/src/theme/pebble/SummerThemeExtension.scala Custom Pebble tags for theme rendering class SummerThemeExtension
from to via pattern
summercms/src/theme/ThemeService.scala summercms/src/theme/ThemeLoader.scala loads theme configuration ThemeLoader.load
from to via pattern
summercms/src/theme/ThemeService.scala io.pebbletemplates.pebble.PebbleEngine renders templates engine.getTemplate
from to via pattern
summercms/src/theme/pebble/PartialTag.scala ThemeService renders partial templates renderPartial
Create the theme loading and layout composition system for SummerCMS.

Purpose: Themes are the visual layer that defines how pages look. This plan establishes theme discovery, configuration parsing (theme.yaml), layout composition with the {% page %} tag, partials with {% partial %}, and the placeholder system ({% placeholder %}/{% put %}). Asset management and Vue integration follow in 04-02.

Output: ThemeService, ThemeConfig, PageConfig, ThemeLoader, custom Pebble tags (partial, page, placeholder, put)

<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/04-theme-engine/04-RESEARCH.md @.planning/phases/04-theme-engine/04-CONTEXT.md @build.mill @summercms/src/component/TemplateService.scala @summercms/src/component/SummerComponent.scala Task 1: Add theme engine dependencies to build.mill build.mill Add the following dependencies to build.mill mvnDeps. Note that Pebble 4.1.1 may already exist from Phase 3 - check first and only add if missing:
// Theme engine - Pebble with Scala wrapper (if not already present)
mvn"io.pebbletemplates:pebble:4.1.1",
mvn"com.sfxcode.templating::pebble-scala:1.0.2",

// Theme engine - ZIO caching for static pages
mvn"com.stuart::zcaffeine:0.9.10",

The dependencies should be added after the existing component system dependencies.

Note:

  • pebble-scala:1.0.2 provides native Scala collection support for Pebble
  • ZCaffeine 0.9.10 will be used in 04-02 for static page caching, but add it now to avoid build changes later
  • circe-yaml and circe-generic should already be present from Phase 2/3 for YAML parsing Run ./mill summercms.compile - dependencies resolve successfully build.mill contains Pebble 4.1.1, pebble-scala 1.0.2, ZCaffeine 0.9.10 dependencies
Task 2: Create theme configuration and error types summercms/src/theme/RenderingMode.scala summercms/src/theme/ThemeError.scala summercms/src/theme/ThemeConfig.scala summercms/src/theme/PageConfig.scala Create `summercms/src/theme/` directory with these files:

RenderingMode.scala:

package theme

import io.circe.*

/** Theme/page rendering mode - determines how frontend is delivered */
enum RenderingMode:
  case Spa     // Server renders HTML, Vue 3 hydrates fully on client
  case Hybrid  // Per-page choice, HTMX for interactions (default)
  case Static  // Pre-generate or cache on first request

object RenderingMode:
  /** Parse from string (case-insensitive) */
  def fromString(s: String): Option[RenderingMode] =
    s.toLowerCase match
      case "spa" => Some(Spa)
      case "hybrid" => Some(Hybrid)
      case "static" => Some(Static)
      case _ => None

  given Decoder[RenderingMode] = Decoder.decodeString.emap { s =>
    fromString(s).toRight(s"Unknown rendering mode: $s. Expected: spa, hybrid, or static")
  }

  given Encoder[RenderingMode] = Encoder.encodeString.contramap(_.toString.toLowerCase)

ThemeError.scala:

package theme

import java.nio.file.Path

/** Theme system error ADT */
enum ThemeError:
  case ThemeNotFound(name: String)
  case ThemeConfigMissing(themePath: Path)
  case ThemeConfigInvalid(themePath: Path, message: String)
  case LayoutNotFound(themeName: String, layoutName: String)
  case PageNotFound(themeName: String, pagePath: String)
  case PartialNotFound(themeName: String, partialName: String)
  case TemplateRenderError(template: String, cause: Throwable)
  case FrontMatterParseError(pagePath: Path, message: String)
  case PlaceholderNotDefined(name: String)
  case CircularPartialInclude(partialName: String, stack: List[String])

  def message: String = this match
    case ThemeNotFound(n) => s"Theme not found: $n"
    case ThemeConfigMissing(p) => s"theme.yaml not found at: $p"
    case ThemeConfigInvalid(p, m) => s"Invalid theme.yaml at $p: $m"
    case LayoutNotFound(t, l) => s"Layout '$l' not found in theme '$t'"
    case PageNotFound(t, p) => s"Page '$p' not found in theme '$t'"
    case PartialNotFound(t, p) => s"Partial '$p' not found in theme '$t'"
    case TemplateRenderError(t, c) => s"Failed to render template '$t': ${c.getMessage}"
    case FrontMatterParseError(p, m) => s"Invalid front matter in '$p': $m"
    case PlaceholderNotDefined(n) => s"Placeholder '$n' not defined in layout"
    case CircularPartialInclude(n, s) => s"Circular partial include detected: $n (stack: ${s.mkString(" -> ")})"

ThemeConfig.scala:

package theme

import io.circe.*
import io.circe.generic.semiauto.*
import java.nio.file.Path

/** Asset configuration in theme.yaml */
case class AssetConfig(
  manifest: Option[String] = None,       // Path to Vite manifest.json relative to theme
  publicPath: Option[String] = None      // Public URL path for assets
)

object AssetConfig:
  given Decoder[AssetConfig] = deriveDecoder[AssetConfig]

/** Vue configuration in theme.yaml */
case class VueConfig(
  entry: Option[String] = None,          // Vue entry point (e.g., "js/app.js")
  hydrateOnLoad: Boolean = true          // Whether to hydrate immediately on page load
)

object VueConfig:
  given Decoder[VueConfig] = deriveDecoder[VueConfig]

/**
 * Theme configuration parsed from theme.yaml
 *
 * Example theme.yaml:
 * ```yaml
 * name: My Theme
 * description: A modern theme for SummerCMS
 * author: Golem15
 * version: 1.0.0
 * rendering: hybrid
 * assets:
 *   manifest: .vite/manifest.json
 *   publicPath: /themes/my-theme/assets/
 * vue:
 *   entry: js/app.js
 *   hydrateOnLoad: true
 * ```
 */
case class ThemeConfig(
  name: String,
  description: String = "",
  author: Option[String] = None,
  version: Option[String] = None,
  rendering: RenderingMode = RenderingMode.Hybrid,
  assets: AssetConfig = AssetConfig(),
  vue: VueConfig = VueConfig(),
  // Runtime - set by ThemeLoader, not from YAML
  path: Path = Path.of("."),
  code: String = ""                       // Theme directory name (e.g., "my-theme")
)

object ThemeConfig:
  given Decoder[ThemeConfig] = deriveDecoder[ThemeConfig]

  /** Create config with runtime path info */
  def withPath(config: ThemeConfig, themePath: Path, themeCode: String): ThemeConfig =
    config.copy(path = themePath, code = themeCode)

PageConfig.scala:

package theme

import io.circe.*
import io.circe.generic.semiauto.*
import io.circe.yaml.parser
import zio.*
import java.nio.file.Path

/**
 * Page configuration from front matter.
 *
 * Example page front matter:
 * ```yaml
 * ---
 * layout: default
 * title: Welcome
 * description: Home page
 * rendering: spa
 * ---
 * Page content here...
 * ```
 */
case class PageConfig(
  layout: String = "default",
  title: Option[String] = None,
  description: Option[String] = None,
  rendering: Option[RenderingMode] = None,   // Override theme default
  url: Option[String] = None,                // Explicit URL (otherwise derived from path)
  // Components declared on this page (Phase 3 integration)
  components: Map[String, Map[String, Any]] = Map.empty,
  // Custom variables available in template
  variables: Map[String, Any] = Map.empty
):
  /** Get effective rendering mode, falling back to theme default */
  def effectiveRendering(themeDefault: RenderingMode): RenderingMode =
    rendering.getOrElse(themeDefault)

object PageConfig:
  // Note: Custom decoder because components/variables need special handling
  given Decoder[PageConfig] = Decoder.instance { cursor =>
    for
      layout <- cursor.getOrElse[String]("layout")("default")
      title <- cursor.get[Option[String]]("title")
      description <- cursor.get[Option[String]]("description")
      rendering <- cursor.get[Option[RenderingMode]]("rendering")
      url <- cursor.get[Option[String]]("url")
    yield PageConfig(
      layout = layout,
      title = title,
      description = description,
      rendering = rendering,
      url = url,
      components = Map.empty,  // Parsed separately if needed
      variables = Map.empty    // Parsed separately if needed
    )
  }

  /** Default config for pages without front matter */
  val default: PageConfig = PageConfig()

  /**
   * Parse front matter from page content.
   * Returns (PageConfig, pageBody) tuple.
   *
   * Front matter is delimited by --- at start and end:
   * ---
   * key: value
   * ---
   * Body content here
   */
  def parseFrontMatter(content: String, path: Path): IO[ThemeError, (PageConfig, String)] =
    ZIO.attempt {
      val lines = content.linesIterator.toList
      if lines.headOption.map(_.trim) != Some("---") then
        // No front matter - use defaults
        (PageConfig.default, content)
      else
        // Find closing ---
        val afterFirst = lines.drop(1)
        val closingIndex = afterFirst.indexWhere(_.trim == "---")
        if closingIndex < 0 then
          // No closing --- - treat entire content as body
          (PageConfig.default, content)
        else
          val yamlLines = afterFirst.take(closingIndex)
          val bodyLines = afterFirst.drop(closingIndex + 1)
          val yamlStr = yamlLines.mkString("\n")
          val body = bodyLines.mkString("\n")

          parser.parse(yamlStr)
            .flatMap(_.as[PageConfig])
            .fold(
              err => throw new RuntimeException(err.getMessage),
              config => (config, body)
            )
    }.mapError(e => ThemeError.FrontMatterParseError(path, e.getMessage))
Run `./mill summercms.compile` - all theme types compile without errors RenderingMode enum, ThemeError ADT, ThemeConfig with asset/vue config, PageConfig with front matter parsing Task 3: Create custom Pebble tags and ThemeService summercms/src/theme/pebble/SummerThemeExtension.scala summercms/src/theme/pebble/PartialTag.scala summercms/src/theme/pebble/PageTag.scala summercms/src/theme/pebble/PlaceholderTag.scala summercms/src/theme/pebble/PutTag.scala summercms/src/theme/ThemeLoader.scala summercms/src/theme/ThemeService.scala Create `summercms/src/theme/pebble/` directory with custom Pebble tags:

PageTag.scala:

package theme.pebble

import io.pebbletemplates.pebble.extension.NodeVisitor
import io.pebbletemplates.pebble.node.{AbstractRenderableNode, RenderableNode}
import io.pebbletemplates.pebble.template.{EvaluationContextImpl, PebbleTemplateImpl}
import io.pebbletemplates.pebble.tokenParser.TokenParser
import io.pebbletemplates.pebble.lexer.Token
import io.pebbletemplates.pebble.parser.Parser
import java.io.Writer

/**
 * {% page %} tag - injects page content into layout.
 * Used in layouts to mark where page body should appear.
 */
class PageTag extends TokenParser:
  override def getTag: String = "page"

  override def parse(token: Token, parser: Parser): RenderableNode =
    val stream = parser.getStream
    stream.expect(classOf[Token.Type], "TAG_END")
    PageNode(token.getLineNumber)

class PageNode(lineNumber: Int) extends AbstractRenderableNode(lineNumber):
  override def render(self: PebbleTemplateImpl, writer: Writer, context: EvaluationContextImpl): Unit =
    // Page content is stored in context by ThemeService before layout rendering
    val pageContent = Option(context.getVariable("__PAGE_CONTENT__"))
      .map(_.toString)
      .getOrElse("")
    writer.write(pageContent)

  override def accept(visitor: NodeVisitor): Unit = visitor.visit(this)

PartialTag.scala:

package theme.pebble

import io.pebbletemplates.pebble.extension.NodeVisitor
import io.pebbletemplates.pebble.node.{AbstractRenderableNode, RenderableNode}
import io.pebbletemplates.pebble.node.expression.Expression
import io.pebbletemplates.pebble.template.{EvaluationContextImpl, PebbleTemplateImpl, ScopeChain}
import io.pebbletemplates.pebble.tokenParser.TokenParser
import io.pebbletemplates.pebble.lexer.Token
import io.pebbletemplates.pebble.parser.Parser
import java.io.{StringWriter, Writer}
import java.util.{Map as JMap}
import scala.jdk.CollectionConverters.*

/**
 * {% partial 'name' %} tag - includes a partial template.
 * Supports variable passing:
 *   {% partial 'card' title='Hello' count=5 %}
 *   {% partial 'card' with cardData %}
 */
class PartialTag extends TokenParser:
  override def getTag: String = "partial"

  override def parse(token: Token, parser: Parser): RenderableNode =
    val stream = parser.getStream
    val lineNumber = token.getLineNumber

    // Parse partial name (string expression)
    val partialName = parser.getExpressionParser.parseExpression()

    // Parse optional arguments
    var namedArgs: Map[String, Expression[?]] = Map.empty
    var withExpr: Option[Expression[?]] = None

    while stream.current().test(classOf[Token.Type], "NAME") do
      val argName = stream.current().getValue
      stream.next()

      if argName == "with" then
        // {% partial 'name' with contextObject %}
        withExpr = Some(parser.getExpressionParser.parseExpression())
      else
        // Named argument: key=value
        stream.expect(classOf[Token.Type], "PUNCTUATION", "=")
        val value = parser.getExpressionParser.parseExpression()
        namedArgs = namedArgs + (argName -> value)

    stream.expect(classOf[Token.Type], "TAG_END")
    PartialNode(lineNumber, partialName, namedArgs.asJava, withExpr.orNull)

class PartialNode(
  lineNumber: Int,
  partialNameExpr: Expression[?],
  namedArgs: JMap[String, Expression[?]],
  withExpr: Expression[?]
) extends AbstractRenderableNode(lineNumber):

  override def render(self: PebbleTemplateImpl, writer: Writer, context: EvaluationContextImpl): Unit =
    val partialName = partialNameExpr.evaluate(self, context).toString

    // Get theme path from context
    val themePath = Option(context.getVariable("__THEME_PATH__"))
      .map(_.toString)
      .getOrElse("themes/default")

    // Build partial context
    val partialContext = new java.util.HashMap[String, AnyRef]()

    // Copy existing variables
    Option(context.getScopeChain).foreach { sc =>
      sc.getKeys.forEach { key =>
        partialContext.put(key, sc.get(key))
      }
    }

    // Add "with" object if specified
    if withExpr != null then
      val withObj = withExpr.evaluate(self, context)
      withObj match
        case map: JMap[?, ?] =>
          map.forEach { (k, v) =>
            partialContext.put(k.toString, v.asInstanceOf[AnyRef])
          }
        case _ =>
          // If not a map, make it available as "it"
          partialContext.put("it", withObj.asInstanceOf[AnyRef])

    // Add named arguments (override with object)
    namedArgs.forEach { (key, expr) =>
      partialContext.put(key, expr.evaluate(self, context).asInstanceOf[AnyRef])
    }

    // Detect circular includes
    val includeStack = Option(context.getVariable("__INCLUDE_STACK__"))
      .map(_.asInstanceOf[java.util.List[String]].asScala.toList)
      .getOrElse(List.empty)

    if includeStack.contains(partialName) then
      throw new RuntimeException(s"Circular partial include: $partialName (stack: ${includeStack.mkString(" -> ")})")

    partialContext.put("__INCLUDE_STACK__", (includeStack :+ partialName).asJava)

    // Load and render partial template
    val partialPath = s"$themePath/partials/$partialName.htm"
    val engine = self.getEngine
    val template = engine.getTemplate(partialPath)
    template.evaluate(writer, partialContext)

  override def accept(visitor: NodeVisitor): Unit = visitor.visit(this)

PlaceholderTag.scala:

package theme.pebble

import io.pebbletemplates.pebble.extension.NodeVisitor
import io.pebbletemplates.pebble.node.{AbstractRenderableNode, RenderableNode}
import io.pebbletemplates.pebble.node.expression.Expression
import io.pebbletemplates.pebble.template.{EvaluationContextImpl, PebbleTemplateImpl}
import io.pebbletemplates.pebble.tokenParser.TokenParser
import io.pebbletemplates.pebble.lexer.Token
import io.pebbletemplates.pebble.parser.Parser
import java.io.Writer

/**
 * {% placeholder 'name' %} tag - defines a named content slot in layout.
 * Content is filled by {% put 'name' %}...{% endput %} in pages.
 */
class PlaceholderTag extends TokenParser:
  override def getTag: String = "placeholder"

  override def parse(token: Token, parser: Parser): RenderableNode =
    val stream = parser.getStream
    val lineNumber = token.getLineNumber

    // Parse placeholder name
    val placeholderName = parser.getExpressionParser.parseExpression()

    stream.expect(classOf[Token.Type], "TAG_END")
    PlaceholderNode(lineNumber, placeholderName)

class PlaceholderNode(
  lineNumber: Int,
  nameExpr: Expression[?]
) extends AbstractRenderableNode(lineNumber):

  override def render(self: PebbleTemplateImpl, writer: Writer, context: EvaluationContextImpl): Unit =
    val name = nameExpr.evaluate(self, context).toString

    // Look for placeholder content in context
    val placeholders = Option(context.getVariable("__PLACEHOLDERS__"))
      .map(_.asInstanceOf[java.util.Map[String, String]])
      .getOrElse(new java.util.HashMap())

    val content = Option(placeholders.get(name)).getOrElse("")
    writer.write(content)

  override def accept(visitor: NodeVisitor): Unit = visitor.visit(this)

PutTag.scala:

package theme.pebble

import io.pebbletemplates.pebble.extension.NodeVisitor
import io.pebbletemplates.pebble.node.{AbstractRenderableNode, RenderableNode, BodyNode}
import io.pebbletemplates.pebble.node.expression.Expression
import io.pebbletemplates.pebble.template.{EvaluationContextImpl, PebbleTemplateImpl}
import io.pebbletemplates.pebble.tokenParser.TokenParser
import io.pebbletemplates.pebble.lexer.Token
import io.pebbletemplates.pebble.parser.Parser
import java.io.{StringWriter, Writer}

/**
 * {% put 'name' %}...{% endput %} tag - fills a placeholder defined in layout.
 * Content between tags is captured and stored for the named placeholder.
 */
class PutTag extends TokenParser:
  override def getTag: String = "put"

  override def parse(token: Token, parser: Parser): RenderableNode =
    val stream = parser.getStream
    val lineNumber = token.getLineNumber

    // Parse placeholder name
    val placeholderName = parser.getExpressionParser.parseExpression()

    stream.expect(classOf[Token.Type], "TAG_END")

    // Parse body until endput
    val body = parser.subparse { t =>
      t.test(classOf[Token.Type], "NAME", "endput")
    }

    // Consume endput tag
    stream.next()
    stream.expect(classOf[Token.Type], "TAG_END")

    PutNode(lineNumber, placeholderName, body)

class PutNode(
  lineNumber: Int,
  nameExpr: Expression[?],
  body: BodyNode
) extends AbstractRenderableNode(lineNumber):

  override def render(self: PebbleTemplateImpl, writer: Writer, context: EvaluationContextImpl): Unit =
    val name = nameExpr.evaluate(self, context).toString

    // Render body to capture content
    val bodyWriter = new StringWriter()
    body.render(self, bodyWriter, context)
    val content = bodyWriter.toString

    // Store in placeholders map
    val placeholders = Option(context.getVariable("__PLACEHOLDERS__"))
      .map(_.asInstanceOf[java.util.Map[String, String]])
      .getOrElse {
        val map = new java.util.HashMap[String, String]()
        context.put("__PLACEHOLDERS__", map)
        map
      }

    placeholders.put(name, content)

  override def accept(visitor: NodeVisitor): Unit =
    visitor.visit(this)
    body.accept(visitor)

SummerThemeExtension.scala:

package theme.pebble

import io.pebbletemplates.pebble.extension.AbstractExtension
import io.pebbletemplates.pebble.tokenParser.TokenParser
import java.util.{Map as JMap, List as JList}
import scala.jdk.CollectionConverters.*

/**
 * Pebble extension providing SummerCMS theme tags.
 * Tags:
 *   {% page %} - inject page content into layout
 *   {% partial 'name' %} - include partial template
 *   {% placeholder 'name' %} - define content slot
 *   {% put 'name' %}...{% endput %} - fill content slot
 */
class SummerThemeExtension extends AbstractExtension:

  override def getTokenParsers: JList[TokenParser] =
    List[TokenParser](
      new PageTag(),
      new PartialTag(),
      new PlaceholderTag(),
      new PutTag()
    ).asJava

ThemeLoader.scala:

package theme

import io.circe.yaml.parser
import zio.*
import java.nio.file.{Files, Path}
import scala.jdk.CollectionConverters.*

/** Service for discovering and loading themes */
trait ThemeLoader:
  /** Load theme from directory */
  def load(themePath: Path): IO[ThemeError, ThemeConfig]

  /** Discover all themes in themes directory */
  def discover(themesDir: Path): IO[ThemeError, List[ThemeConfig]]

object ThemeLoader:
  /** Load a theme */
  def load(themePath: Path): ZIO[ThemeLoader, ThemeError, ThemeConfig] =
    ZIO.serviceWithZIO[ThemeLoader](_.load(themePath))

  /** Discover all themes */
  def discover(themesDir: Path): ZIO[ThemeLoader, ThemeError, List[ThemeConfig]] =
    ZIO.serviceWithZIO[ThemeLoader](_.discover(themesDir))

  /** Default themes directory */
  val defaultThemesDir: Path = Path.of("themes")

  /** Live implementation */
  val live: ULayer[ThemeLoader] = ZLayer.succeed {
    new ThemeLoader:
      def load(themePath: Path): IO[ThemeError, ThemeConfig] =
        val configPath = themePath.resolve("theme.yaml")
        val themeCode = themePath.getFileName.toString
        for
          exists <- ZIO.attemptBlocking(Files.exists(configPath)).orDie
          _ <- ZIO.fail(ThemeError.ThemeConfigMissing(themePath)).when(!exists)
          content <- ZIO.attemptBlocking(Files.readString(configPath))
            .mapError(e => ThemeError.ThemeConfigInvalid(themePath, e.getMessage))
          config <- ZIO.fromEither(
            parser.parse(content)
              .flatMap(_.as[ThemeConfig])
              .left.map(e => ThemeError.ThemeConfigInvalid(themePath, e.getMessage))
          )
        yield ThemeConfig.withPath(config, themePath, themeCode)

      def discover(themesDir: Path): IO[ThemeError, List[ThemeConfig]] =
        for
          exists <- ZIO.attemptBlocking(Files.exists(themesDir)).orDie
          themes <- if !exists then ZIO.succeed(List.empty)
            else
              ZIO.attemptBlocking {
                Files.list(themesDir)
                  .filter(Files.isDirectory(_))
                  .iterator()
                  .asScala
                  .toList
              }.orDie.flatMap { dirs =>
                ZIO.foreach(dirs)(dir => load(dir).option).map(_.flatten)
              }
        yield themes
  }

ThemeService.scala:

package theme

import io.pebbletemplates.pebble.PebbleEngine
import io.pebbletemplates.pebble.loader.FileLoader
import theme.pebble.SummerThemeExtension
import zio.*
import java.io.StringWriter
import java.nio.file.{Files, Path}
import scala.jdk.CollectionConverters.*

/** Service for rendering themes */
trait ThemeService:
  /** Get currently active theme */
  def getActive: UIO[Option[ThemeConfig]]

  /** Set active theme by code */
  def setActive(themeCode: String): IO[ThemeError, Unit]

  /** Render a page with its layout */
  def renderPage(pagePath: String, context: Map[String, Any]): IO[ThemeError, String]

  /** Render a partial template */
  def renderPartial(partialName: String, context: Map[String, Any]): IO[ThemeError, String]

  /** Render a layout with page content */
  def renderLayout(layoutName: String, pageContent: String, context: Map[String, Any]): IO[ThemeError, String]

  /** Get page configuration from front matter */
  def getPageConfig(pagePath: String): IO[ThemeError, PageConfig]

object ThemeService:
  /** Get active theme */
  def getActive: ZIO[ThemeService, Nothing, Option[ThemeConfig]] =
    ZIO.serviceWithZIO[ThemeService](_.getActive)

  /** Render a page */
  def renderPage(pagePath: String, context: Map[String, Any]): ZIO[ThemeService, ThemeError, String] =
    ZIO.serviceWithZIO[ThemeService](_.renderPage(pagePath, context))

  /** Render a partial */
  def renderPartial(partialName: String, context: Map[String, Any]): ZIO[ThemeService, ThemeError, String] =
    ZIO.serviceWithZIO[ThemeService](_.renderPartial(partialName, context))

  /** Live implementation */
  def live(themesDir: Path): ZLayer[ThemeLoader, ThemeError, ThemeService] =
    ZLayer.fromZIO {
      for
        loader <- ZIO.service[ThemeLoader]
        themes <- loader.discover(themesDir)
        activeRef <- Ref.make(themes.headOption)
        enginesRef <- Ref.make(Map.empty[String, PebbleEngine])
      yield new ThemeServiceLive(themesDir, themes, activeRef, enginesRef, loader)
    }

  /** Default layer */
  val default: ZLayer[ThemeLoader, ThemeError, ThemeService] =
    live(ThemeLoader.defaultThemesDir)

private class ThemeServiceLive(
  themesDir: Path,
  initialThemes: List[ThemeConfig],
  activeRef: Ref[Option[ThemeConfig]],
  enginesRef: Ref[Map[String, PebbleEngine]],
  loader: ThemeLoader
) extends ThemeService:

  def getActive: UIO[Option[ThemeConfig]] =
    activeRef.get

  def setActive(themeCode: String): IO[ThemeError, Unit] =
    for
      themePath <- ZIO.succeed(themesDir.resolve(themeCode))
      config <- loader.load(themePath)
      _ <- activeRef.set(Some(config))
    yield ()

  def renderPage(pagePath: String, context: Map[String, Any]): IO[ThemeError, String] =
    for
      theme <- activeRef.get.flatMap {
        case Some(t) => ZIO.succeed(t)
        case None => ZIO.fail(ThemeError.ThemeNotFound("no active theme"))
      }

      // Load page file
      pageFile = theme.path.resolve("pages").resolve(s"$pagePath.htm")
      exists <- ZIO.attemptBlocking(Files.exists(pageFile)).orDie
      _ <- ZIO.fail(ThemeError.PageNotFound(theme.name, pagePath)).when(!exists)

      content <- ZIO.attemptBlocking(Files.readString(pageFile))
        .mapError(e => ThemeError.TemplateRenderError(pagePath, e))

      // Parse front matter
      (pageConfig, pageBody) <- PageConfig.parseFrontMatter(content, pageFile)

      // Get Pebble engine for this theme
      engine <- getOrCreateEngine(theme)

      // Render page body
      pageContext = buildContext(theme, pageConfig, context)
      renderedPage <- renderTemplate(engine, pageBody, pageContext, s"page:$pagePath")

      // Collect placeholders from page rendering
      placeholders = pageContext.getOrElse("__PLACEHOLDERS__", new java.util.HashMap[String, String]())
        .asInstanceOf[java.util.Map[String, String]]

      // Render layout with page content
      layoutContext = buildContext(theme, pageConfig, context) ++
        Map("__PAGE_CONTENT__" -> renderedPage, "__PLACEHOLDERS__" -> placeholders)
      layoutFile = theme.path.resolve("layouts").resolve(s"${pageConfig.layout}.htm")
      layoutExists <- ZIO.attemptBlocking(Files.exists(layoutFile)).orDie
      _ <- ZIO.fail(ThemeError.LayoutNotFound(theme.name, pageConfig.layout)).when(!layoutExists)

      layoutContent <- ZIO.attemptBlocking(Files.readString(layoutFile))
        .mapError(e => ThemeError.TemplateRenderError(pageConfig.layout, e))

      renderedLayout <- renderTemplate(engine, layoutContent, layoutContext, s"layout:${pageConfig.layout}")
    yield renderedLayout

  def renderPartial(partialName: String, context: Map[String, Any]): IO[ThemeError, String] =
    for
      theme <- activeRef.get.flatMap {
        case Some(t) => ZIO.succeed(t)
        case None => ZIO.fail(ThemeError.ThemeNotFound("no active theme"))
      }

      partialFile = theme.path.resolve("partials").resolve(s"$partialName.htm")
      exists <- ZIO.attemptBlocking(Files.exists(partialFile)).orDie
      _ <- ZIO.fail(ThemeError.PartialNotFound(theme.name, partialName)).when(!exists)

      content <- ZIO.attemptBlocking(Files.readString(partialFile))
        .mapError(e => ThemeError.TemplateRenderError(partialName, e))

      engine <- getOrCreateEngine(theme)
      partialContext = buildContext(theme, PageConfig.default, context)
      rendered <- renderTemplate(engine, content, partialContext, s"partial:$partialName")
    yield rendered

  def renderLayout(layoutName: String, pageContent: String, context: Map[String, Any]): IO[ThemeError, String] =
    for
      theme <- activeRef.get.flatMap {
        case Some(t) => ZIO.succeed(t)
        case None => ZIO.fail(ThemeError.ThemeNotFound("no active theme"))
      }

      layoutFile = theme.path.resolve("layouts").resolve(s"$layoutName.htm")
      exists <- ZIO.attemptBlocking(Files.exists(layoutFile)).orDie
      _ <- ZIO.fail(ThemeError.LayoutNotFound(theme.name, layoutName)).when(!exists)

      content <- ZIO.attemptBlocking(Files.readString(layoutFile))
        .mapError(e => ThemeError.TemplateRenderError(layoutName, e))

      engine <- getOrCreateEngine(theme)
      layoutContext = buildContext(theme, PageConfig.default, context) + ("__PAGE_CONTENT__" -> pageContent)
      rendered <- renderTemplate(engine, content, layoutContext, s"layout:$layoutName")
    yield rendered

  def getPageConfig(pagePath: String): IO[ThemeError, PageConfig] =
    for
      theme <- activeRef.get.flatMap {
        case Some(t) => ZIO.succeed(t)
        case None => ZIO.fail(ThemeError.ThemeNotFound("no active theme"))
      }

      pageFile = theme.path.resolve("pages").resolve(s"$pagePath.htm")
      exists <- ZIO.attemptBlocking(Files.exists(pageFile)).orDie
      _ <- ZIO.fail(ThemeError.PageNotFound(theme.name, pagePath)).when(!exists)

      content <- ZIO.attemptBlocking(Files.readString(pageFile))
        .mapError(e => ThemeError.FrontMatterParseError(pageFile, e.getMessage))

      (config, _) <- PageConfig.parseFrontMatter(content, pageFile)
    yield config

  private def getOrCreateEngine(theme: ThemeConfig): UIO[PebbleEngine] =
    enginesRef.modify { engines =>
      engines.get(theme.code) match
        case Some(engine) => (engine, engines)
        case None =>
          val engine = new PebbleEngine.Builder()
            .loader(new FileLoader())
            .autoEscaping(true)
            .strictVariables(false)
            .extension(new SummerThemeExtension())
            .cacheActive(true)  // Enable caching in production
            .build()
          (engine, engines + (theme.code -> engine))
    }

  private def renderTemplate(
    engine: PebbleEngine,
    templateContent: String,
    context: Map[String, Any],
    templateName: String
  ): IO[ThemeError, String] =
    ZIO.attemptBlocking {
      // Use string loader for dynamic content
      val stringEngine = new PebbleEngine.Builder()
        .loader(new io.pebbletemplates.pebble.loader.StringLoader())
        .autoEscaping(true)
        .strictVariables(false)
        .extension(new SummerThemeExtension())
        .build()

      val template = stringEngine.getTemplate(templateContent)
      val writer = new StringWriter()
      template.evaluate(writer, toJavaContext(context))
      writer.toString
    }.mapError(e => ThemeError.TemplateRenderError(templateName, e))

  private def buildContext(
    theme: ThemeConfig,
    pageConfig: PageConfig,
    extraContext: Map[String, Any]
  ): Map[String, Any] =
    Map(
      "theme" -> Map(
        "name" -> theme.name,
        "code" -> theme.code,
        "rendering" -> theme.rendering.toString.toLowerCase
      ),
      "page" -> Map(
        "title" -> pageConfig.title.getOrElse(""),
        "description" -> pageConfig.description.getOrElse(""),
        "layout" -> pageConfig.layout
      ),
      "renderingMode" -> pageConfig.effectiveRendering(theme.rendering).toString.toLowerCase,
      "__THEME_PATH__" -> theme.path.toString,
      "__PLACEHOLDERS__" -> new java.util.HashMap[String, String]()
    ) ++ extraContext

  private def toJavaContext(context: Map[String, Any]): java.util.Map[String, AnyRef] =
    context.view.mapValues(toJavaValue).toMap.asJava

  private def toJavaValue(value: Any): AnyRef = value match
    case null => null
    case s: String => s
    case n: Int => Integer.valueOf(n)
    case n: Long => java.lang.Long.valueOf(n)
    case n: Double => java.lang.Double.valueOf(n)
    case b: Boolean => java.lang.Boolean.valueOf(b)
    case m: Map[?, ?] => m.map { case (k, v) => k.toString -> toJavaValue(v) }.asJava
    case l: List[?] => l.map(toJavaValue).asJava
    case l: Seq[?] => l.map(toJavaValue).asJava
    case opt: Option[?] => opt.map(toJavaValue).orNull
    case other => other.asInstanceOf[AnyRef]
Run `./mill summercms.compile` - all files compile.

Create a test theme structure for manual verification:

mkdir -p themes/test-theme/{layouts,pages,partials}

cat > themes/test-theme/theme.yaml << 'EOF'
name: Test Theme
description: A test theme for SummerCMS
rendering: hybrid
EOF

cat > themes/test-theme/layouts/default.htm << 'EOF'
<!DOCTYPE html>
<html>
<head>
  <title>{{ page.title }} | {{ theme.name }}</title>
  {% placeholder 'styles' %}
</head>
<body>
  {% partial 'header' %}
  <main>
    {% page %}
  </main>
  {% placeholder 'scripts' %}
</body>
</html>
EOF

cat > themes/test-theme/partials/header.htm << 'EOF'
<header>
  <h1>{{ theme.name }}</h1>
</header>
EOF

cat > themes/test-theme/pages/home.htm << 'EOF'
---
layout: default
title: Home
---
<h2>Welcome</h2>
{% put 'scripts' %}
<script>console.log('home');</script>
{% endput %}
EOF

Test in Mill console:

import theme._
import zio._

val program = for {
  _ <- ThemeLoader.load(java.nio.file.Path.of("themes/test-theme"))
    .provide(ThemeLoader.live)
  html <- ThemeService.renderPage("home", Map.empty)
    .provide(ThemeLoader.live >>> ThemeService.default)
  _ <- Console.printLine(html)
} yield ()

Unsafe.unsafe { implicit unsafe =>
  Runtime.default.unsafe.run(program).getOrThrow()
}
Custom Pebble tags (page, partial, placeholder, put), ThemeLoader for discovery, ThemeService for rendering pages with layout composition 1. `./mill summercms.compile` succeeds with all new dependencies and types 2. Theme discovery finds themes in themes/ directory 3. theme.yaml parses to ThemeConfig with rendering mode 4. Page front matter parses to PageConfig with layout and title 5. {% page %} tag injects page content into layout 6. {% partial 'name' %} includes partial templates 7. {% placeholder 'name' %} and {% put 'name' %} work for content slots 8. Circular partial includes are detected and throw error

<success_criteria>

  • build.mill has pebble-scala, ZCaffeine dependencies
  • ThemeConfig parses from theme.yaml with rendering mode, assets, vue config
  • PageConfig parses front matter with layout, title, rendering override
  • ThemeLoader discovers themes and loads configuration
  • ThemeService renders pages with layout composition
  • Custom Pebble tags: page, partial, placeholder, put
  • Partial includes support variable passing (named args and "with") </success_criteria>
After completion, create `.planning/phases/04-theme-engine/04-01-SUMMARY.md`