--- 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" --- 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) @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md @.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: ```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 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:** ```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)) ``` 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:** ```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] ``` 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' {{ page.title }} | {{ theme.name }} {% placeholder 'styles' %} {% partial 'header' %}
{% page %}
{% placeholder 'scripts' %} EOF cat > themes/test-theme/partials/header.htm << 'EOF'

{{ theme.name }}

EOF cat > themes/test-theme/pages/home.htm << 'EOF' --- layout: default title: Home ---

Welcome

{% put 'scripts' %} {% 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() } ```
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 - 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") After completion, create `.planning/phases/04-theme-engine/04-01-SUMMARY.md`