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

1068 lines
37 KiB
Markdown

---
phase: 04-theme-engine
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- 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
autonomous: true
must_haves:
truths:
- "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"
artifacts:
- path: "summercms/src/theme/ThemeService.scala"
provides: "Theme rendering service"
exports: ["ThemeService"]
- path: "summercms/src/theme/ThemeConfig.scala"
provides: "Theme metadata from theme.yaml"
contains: "case class ThemeConfig"
- path: "summercms/src/theme/PageConfig.scala"
provides: "Page front matter parsing"
contains: "case class PageConfig"
- path: "summercms/src/theme/pebble/SummerThemeExtension.scala"
provides: "Custom Pebble tags for theme rendering"
contains: "class SummerThemeExtension"
key_links:
- from: "summercms/src/theme/ThemeService.scala"
to: "summercms/src/theme/ThemeLoader.scala"
via: "loads theme configuration"
pattern: "ThemeLoader\\.load"
- from: "summercms/src/theme/ThemeService.scala"
to: "io.pebbletemplates.pebble.PebbleEngine"
via: "renders templates"
pattern: "engine\\.getTemplate"
- from: "summercms/src/theme/pebble/PartialTag.scala"
to: "ThemeService"
via: "renders partial templates"
pattern: "renderPartial"
---
<objective>
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)
</objective>
<execution_context>
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/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
</context>
<tasks>
<task type="auto">
<name>Task 1: Add theme engine dependencies to build.mill</name>
<files>build.mill</files>
<action>
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:
```scala
// 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
</action>
<verify>Run `./mill summercms.compile` - dependencies resolve successfully</verify>
<done>build.mill contains Pebble 4.1.1, pebble-scala 1.0.2, ZCaffeine 0.9.10 dependencies</done>
</task>
<task type="auto">
<name>Task 2: Create theme configuration and error types</name>
<files>
summercms/src/theme/RenderingMode.scala
summercms/src/theme/ThemeError.scala
summercms/src/theme/ThemeConfig.scala
summercms/src/theme/PageConfig.scala
</files>
<action>
Create `summercms/src/theme/` directory with these files:
**RenderingMode.scala:**
```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:**
```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:**
```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:**
```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))
```
</action>
<verify>Run `./mill summercms.compile` - all theme types compile without errors</verify>
<done>RenderingMode enum, ThemeError ADT, ThemeConfig with asset/vue config, PageConfig with front matter parsing</done>
</task>
<task type="auto">
<name>Task 3: Create custom Pebble tags and ThemeService</name>
<files>
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
</files>
<action>
Create `summercms/src/theme/pebble/` directory with custom Pebble tags:
**PageTag.scala:**
```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:**
```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:**
```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:**
```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:**
```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:**
```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:**
```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]
```
</action>
<verify>
Run `./mill summercms.compile` - all files compile.
Create a test theme structure for manual verification:
```bash
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:
```scala
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()
}
```
</verify>
<done>Custom Pebble tags (page, partial, placeholder, put), ThemeLoader for discovery, ThemeService for rendering pages with layout composition</done>
</task>
</tasks>
<verification>
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
</verification>
<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>
<output>
After completion, create `.planning/phases/04-theme-engine/04-01-SUMMARY.md`
</output>