From 44685de65ee80b468567f3d0c0a83f74b5fade42 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 5 Feb 2026 14:06:11 +0100 Subject: [PATCH] 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 --- .planning/ROADMAP.md | 8 +- .../phases/04-theme-engine/04-01-PLAN.md | 1067 ++++++++++++++ .../phases/04-theme-engine/04-02-PLAN.md | 1310 +++++++++++++++++ 3 files changed, 2381 insertions(+), 4 deletions(-) create mode 100644 .planning/phases/04-theme-engine/04-01-PLAN.md create mode 100644 .planning/phases/04-theme-engine/04-02-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index b52d53e..7bddf2e 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -84,11 +84,11 @@ Plans: 3. Asset management bundles and serves CSS/JS from theme 4. Theme can declare rendering mode (SPA, hybrid, static) 5. Vue components integrate into theme pages -**Plans**: TBD +**Plans**: 2 plans Plans: -- [ ] 04-01: Theme loading and layout composition -- [ ] 04-02: Asset pipeline and Vue integration +- [ ] 04-01-PLAN.md - Theme loading and layout composition (ThemeConfig, ThemeLoader, ThemeService, custom Pebble tags) +- [ ] 04-02-PLAN.md - Asset pipeline and Vue integration (AssetService, ViteManifest, StaticPageCache, PagePipeline) ### Phase 5: CLI Scaffolding **Goal**: Provide developer tools for rapid project setup @@ -209,7 +209,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10 | 1. Foundation | 3/3 | Complete | 2026-02-05 | | 2. Plugin System | 0/3 | Planned | - | | 3. Component System | 0/2 | Planned | - | -| 4. Theme Engine | 0/2 | Not started | - | +| 4. Theme Engine | 0/2 | Planned | - | | 5. CLI Scaffolding | 0/2 | Not started | - | | 6. Backend Authentication | 0/3 | Not started | - | | 7. Admin Forms & Lists | 0/3 | Not started | - | diff --git a/.planning/phases/04-theme-engine/04-01-PLAN.md b/.planning/phases/04-theme-engine/04-01-PLAN.md new file mode 100644 index 0000000..f412661 --- /dev/null +++ b/.planning/phases/04-theme-engine/04-01-PLAN.md @@ -0,0 +1,1067 @@ +--- +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` + diff --git a/.planning/phases/04-theme-engine/04-02-PLAN.md b/.planning/phases/04-theme-engine/04-02-PLAN.md new file mode 100644 index 0000000..d69d81b --- /dev/null +++ b/.planning/phases/04-theme-engine/04-02-PLAN.md @@ -0,0 +1,1310 @@ +--- +phase: 04-theme-engine +plan: 02 +type: execute +wave: 2 +depends_on: ["04-01"] +files_modified: + - summercms/src/theme/asset/AssetService.scala + - summercms/src/theme/asset/ViteManifest.scala + - summercms/src/theme/asset/AssetRegistry.scala + - summercms/src/theme/static/StaticPageCache.scala + - summercms/src/theme/pebble/AssetFunctions.scala + - summercms/src/theme/ThemeRoutes.scala + - summercms/src/theme/PagePipeline.scala + - summercms/src/theme/package.scala + - summercms/src/api/Routes.scala + - summercms/src/Main.scala +autonomous: true + +must_haves: + truths: + - "Assets resolve via Vite manifest in production mode" + - "Assets resolve to Vite dev server URL in development mode" + - "{{ assetLink('css/app.css') }} outputs correct script/link tags" + - "{{ vueHydrationScript() }} outputs Vue hydration code for SPA mode" + - "Static pages are cached with ZCaffeine" + - "Plugins can inject CSS/JS via addCss() and addJs()" + - "Theme assets are served from /themes/{code}/assets/" + artifacts: + - path: "summercms/src/theme/asset/AssetService.scala" + provides: "Asset resolution with dev/prod modes" + exports: ["AssetService"] + - path: "summercms/src/theme/asset/ViteManifest.scala" + provides: "Vite manifest parsing" + contains: "case class ViteManifest" + - path: "summercms/src/theme/asset/AssetRegistry.scala" + provides: "Plugin asset injection (addCss/addJs)" + exports: ["AssetRegistry"] + - path: "summercms/src/theme/static/StaticPageCache.scala" + provides: "ZCaffeine-based page caching" + exports: ["StaticPageCache"] + - path: "summercms/src/theme/PagePipeline.scala" + provides: "Complete page rendering pipeline" + contains: "object PagePipeline" + key_links: + - from: "summercms/src/theme/asset/AssetService.scala" + to: "summercms/src/theme/asset/ViteManifest.scala" + via: "reads manifest for production paths" + pattern: "manifest\\.entries" + - from: "summercms/src/theme/pebble/AssetFunctions.scala" + to: "summercms/src/theme/asset/AssetService.scala" + via: "resolves assets in templates" + pattern: "AssetService\\.scriptTag" + - from: "summercms/src/theme/PagePipeline.scala" + to: "summercms/src/theme/static/StaticPageCache.scala" + via: "caches static pages" + pattern: "StaticPageCache\\.get" +--- + + +Implement asset pipeline and Vue integration for SummerCMS themes. + +Purpose: Themes need to serve CSS/JS assets efficiently in both development (Vite dev server) and production (bundled with manifest). This plan creates the AssetService for resolving assets, Vite manifest parsing, ZCaffeine caching for static pages, Vue hydration support, and the complete page rendering pipeline that ties everything together. + +Output: AssetService, ViteManifest, AssetRegistry, StaticPageCache, asset template functions, ThemeRoutes, PagePipeline + + + +@/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 +@.planning/phases/04-theme-engine/04-01-SUMMARY.md +@summercms/src/theme/ThemeService.scala +@summercms/src/theme/ThemeConfig.scala +@summercms/src/theme/pebble/SummerThemeExtension.scala +@summercms/src/config/AppConfig.scala + + + + + + Task 1: Create Vite manifest parsing and AssetService + + summercms/src/theme/asset/ViteManifest.scala + summercms/src/theme/asset/AssetService.scala + summercms/src/theme/asset/AssetRegistry.scala + + +Create `summercms/src/theme/asset/` directory with these files: + +**ViteManifest.scala:** +```scala +package theme.asset + +import io.circe.* +import io.circe.generic.semiauto.* +import io.circe.parser +import zio.* +import java.nio.file.{Files, Path} + +/** + * Entry in Vite manifest.json + * + * Example manifest entry: + * ```json + * "assets/js/app.js": { + * "file": "assets/app-DGUgL8Sc.js", + * "src": "assets/js/app.js", + * "isEntry": true, + * "css": ["assets/app-BjM3uX9k.css"], + * "imports": ["_vendor-C9F2ksZW.js"] + * } + * ``` + */ +case class ManifestEntry( + file: String, + src: Option[String] = None, + isEntry: Option[Boolean] = None, + isDynamicEntry: Option[Boolean] = None, + css: List[String] = List.empty, + assets: List[String] = List.empty, + imports: List[String] = List.empty, + dynamicImports: List[String] = List.empty +) + +object ManifestEntry: + given Decoder[ManifestEntry] = deriveDecoder[ManifestEntry] + +/** + * Vite manifest for production asset resolution. + * Maps source paths to hashed output paths. + */ +case class ViteManifest(entries: Map[String, ManifestEntry]): + /** Get entry by source path */ + def get(srcPath: String): Option[ManifestEntry] = + entries.get(srcPath) + + /** Get all CSS files for an entry (including from imports) */ + def getCssForEntry(srcPath: String): List[String] = + entries.get(srcPath).map { entry => + val directCss = entry.css + val importedCss = entry.imports.flatMap { imp => + entries.get(imp).map(_.css).getOrElse(List.empty) + } + directCss ++ importedCss + }.getOrElse(List.empty) + + /** Get all JS files for an entry (including imports) */ + def getJsForEntry(srcPath: String): List[String] = + entries.get(srcPath).map { entry => + val mainFile = entry.file + val importedFiles = entry.imports.flatMap { imp => + entries.get(imp).map(_.file) + } + mainFile :: importedFiles + }.getOrElse(List.empty) + +object ViteManifest: + given Decoder[ViteManifest] = Decoder.decodeMap[String, ManifestEntry].map(ViteManifest(_)) + + /** Parse manifest from JSON string */ + def parse(json: String): Either[String, ViteManifest] = + parser.parse(json) + .flatMap(_.as[ViteManifest]) + .left.map(_.getMessage) + + /** Load manifest from file */ + def load(path: Path): IO[AssetError, ViteManifest] = + for + exists <- ZIO.attemptBlocking(Files.exists(path)).orDie + _ <- ZIO.fail(AssetError.ManifestNotFound(path)).when(!exists) + content <- ZIO.attemptBlocking(Files.readString(path)) + .mapError(e => AssetError.ManifestReadError(path, e.getMessage)) + manifest <- ZIO.fromEither(parse(content)) + .mapError(e => AssetError.ManifestParseError(path, e)) + yield manifest + + /** Empty manifest for development mode */ + val empty: ViteManifest = ViteManifest(Map.empty) + +/** Asset-related errors */ +enum AssetError: + case ManifestNotFound(path: Path) + case ManifestReadError(path: Path, message: String) + case ManifestParseError(path: Path, message: String) + case AssetNotFound(entry: String) + case InvalidAssetPath(path: String) + + def message: String = this match + case ManifestNotFound(p) => s"Vite manifest not found: $p" + case ManifestReadError(p, m) => s"Failed to read manifest at $p: $m" + case ManifestParseError(p, m) => s"Failed to parse manifest at $p: $m" + case AssetNotFound(e) => s"Asset not found in manifest: $e" + case InvalidAssetPath(p) => s"Invalid asset path: $p" +``` + +**AssetService.scala:** +```scala +package theme.asset + +import theme.ThemeConfig +import zio.* +import java.nio.file.Path + +/** HTML type for asset tags */ +case class AssetHtml(value: String): + override def toString: String = value + +/** + * Service for resolving and generating asset URLs/tags. + * Handles both development (Vite dev server) and production (manifest) modes. + */ +trait AssetService: + /** Generate script tag(s) for a JS entry */ + def scriptTag(entry: String): IO[AssetError, AssetHtml] + + /** Generate link tag(s) for a CSS entry */ + def linkTag(entry: String): IO[AssetError, AssetHtml] + + /** Resolve asset path (returns URL string) */ + def resolveAsset(path: String): IO[AssetError, String] + + /** Generate Vue hydration script */ + def vueHydrationScript(vueEntry: Option[String]): UIO[AssetHtml] + + /** Check if in development mode */ + def isDevelopment: UIO[Boolean] + +object AssetService: + /** Generate script tag */ + def scriptTag(entry: String): ZIO[AssetService, AssetError, AssetHtml] = + ZIO.serviceWithZIO[AssetService](_.scriptTag(entry)) + + /** Generate link tag */ + def linkTag(entry: String): ZIO[AssetService, AssetError, AssetHtml] = + ZIO.serviceWithZIO[AssetService](_.linkTag(entry)) + + /** Resolve asset path */ + def resolveAsset(path: String): ZIO[AssetService, AssetError, String] = + ZIO.serviceWithZIO[AssetService](_.resolveAsset(path)) + + /** Vue hydration script */ + def vueHydrationScript(vueEntry: Option[String]): ZIO[AssetService, Nothing, AssetHtml] = + ZIO.serviceWithZIO[AssetService](_.vueHydrationScript(vueEntry)) + + /** + * Create AssetService for a theme. + * @param theme Theme configuration + * @param devMode Whether in development mode (use Vite dev server) + * @param viteDevUrl Vite dev server URL (default: http://localhost:5173) + */ + def make( + theme: ThemeConfig, + devMode: Boolean, + viteDevUrl: String = "http://localhost:5173" + ): ZIO[Any, AssetError, AssetService] = + for + manifest <- if devMode then ZIO.succeed(ViteManifest.empty) + else + val manifestPath = theme.assets.manifest + .map(m => theme.path.resolve(m)) + .getOrElse(theme.path.resolve(".vite/manifest.json")) + ViteManifest.load(manifestPath).catchSome { + case AssetError.ManifestNotFound(_) => + // In dev mode or if manifest doesn't exist, use empty + ZIO.succeed(ViteManifest.empty) + } + yield new AssetServiceLive(theme, manifest, devMode, viteDevUrl) + + /** Layer for AssetService - requires active theme from ThemeService */ + def live(devMode: Boolean): ZLayer[ThemeConfig, AssetError, AssetService] = + ZLayer.fromZIO { + ZIO.serviceWithZIO[ThemeConfig](theme => make(theme, devMode)) + } + +private class AssetServiceLive( + theme: ThemeConfig, + manifest: ViteManifest, + devMode: Boolean, + viteDevUrl: String +) extends AssetService: + + private val publicPath = theme.assets.publicPath.getOrElse(s"/themes/${theme.code}/assets/") + + def isDevelopment: UIO[Boolean] = ZIO.succeed(devMode) + + def scriptTag(entry: String): IO[AssetError, AssetHtml] = + if devMode then + // Development: point to Vite dev server + val html = s""" +""" + ZIO.succeed(AssetHtml(html)) + else + // Production: use manifest + manifest.get(entry) match + case Some(e) => + val scripts = manifest.getJsForEntry(entry).map { file => + s"""""" + } + ZIO.succeed(AssetHtml(scripts.mkString("\n"))) + case None => + ZIO.fail(AssetError.AssetNotFound(entry)) + + def linkTag(entry: String): IO[AssetError, AssetHtml] = + if devMode then + // Development: CSS is injected by Vite HMR + // Return empty or direct link depending on preference + val html = s"""""" + ZIO.succeed(AssetHtml(html)) + else + // Production: get CSS from manifest + val cssFiles = manifest.getCssForEntry(entry) + if cssFiles.isEmpty then + // Try direct CSS file + manifest.get(entry).map(_.file) match + case Some(file) => + ZIO.succeed(AssetHtml(s"""""")) + case None => + ZIO.fail(AssetError.AssetNotFound(entry)) + else + val links = cssFiles.map { file => + s"""""" + } + ZIO.succeed(AssetHtml(links.mkString("\n"))) + + def resolveAsset(path: String): IO[AssetError, String] = + if devMode then + ZIO.succeed(s"$viteDevUrl/$path") + else + manifest.get(path).map(_.file) match + case Some(file) => ZIO.succeed(s"$publicPath$file") + case None => ZIO.succeed(s"$publicPath$path") // Fallback to direct path + + def vueHydrationScript(vueEntry: Option[String]): UIO[AssetHtml] = + val entry = vueEntry.orElse(theme.vue.entry).getOrElse("js/app.js") + if devMode then + ZIO.succeed(AssetHtml(s""" + +""")) + else + ZIO.succeed(AssetHtml(s""" +""")) +``` + +**AssetRegistry.scala:** +```scala +package theme.asset + +import zio.* + +/** + * Registry for dynamically injected CSS/JS assets. + * Plugins and components can inject assets via addCss/addJs. + */ +trait AssetRegistry: + /** Add a CSS file/URL to be included */ + def addCss(path: String, attributes: Map[String, String] = Map.empty): UIO[Unit] + + /** Add a JS file/URL to be included */ + def addJs(path: String, attributes: Map[String, String] = Map.empty): UIO[Unit] + + /** Get all registered CSS */ + def getCss: UIO[List[AssetInjection]] + + /** Get all registered JS */ + def getJs: UIO[List[AssetInjection]] + + /** Clear all registered assets (between requests) */ + def clear: UIO[Unit] + +/** Represents an injected asset */ +case class AssetInjection( + path: String, + attributes: Map[String, String] = Map.empty +): + def isExternal: Boolean = path.startsWith("http://") || path.startsWith("https://") || path.startsWith("//") + +object AssetRegistry: + /** Add CSS */ + def addCss(path: String, attributes: Map[String, String] = Map.empty): ZIO[AssetRegistry, Nothing, Unit] = + ZIO.serviceWithZIO[AssetRegistry](_.addCss(path, attributes)) + + /** Add JS */ + def addJs(path: String, attributes: Map[String, String] = Map.empty): ZIO[AssetRegistry, Nothing, Unit] = + ZIO.serviceWithZIO[AssetRegistry](_.addJs(path, attributes)) + + /** Get all CSS */ + def getCss: ZIO[AssetRegistry, Nothing, List[AssetInjection]] = + ZIO.serviceWithZIO[AssetRegistry](_.getCss) + + /** Get all JS */ + def getJs: ZIO[AssetRegistry, Nothing, List[AssetInjection]] = + ZIO.serviceWithZIO[AssetRegistry](_.getJs) + + /** Live implementation */ + val live: ULayer[AssetRegistry] = ZLayer.fromZIO { + for + cssRef <- Ref.make(List.empty[AssetInjection]) + jsRef <- Ref.make(List.empty[AssetInjection]) + yield new AssetRegistry: + def addCss(path: String, attributes: Map[String, String]): UIO[Unit] = + cssRef.update(_ :+ AssetInjection(path, attributes)) + + def addJs(path: String, attributes: Map[String, String]): UIO[Unit] = + jsRef.update(_ :+ AssetInjection(path, attributes)) + + def getCss: UIO[List[AssetInjection]] = cssRef.get + def getJs: UIO[List[AssetInjection]] = jsRef.get + + def clear: UIO[Unit] = + cssRef.set(List.empty) *> jsRef.set(List.empty) + } +``` + + Run `./mill summercms.compile` - ViteManifest, AssetService, AssetRegistry compile + ViteManifest parses Vite output, AssetService resolves assets in dev/prod modes, AssetRegistry tracks plugin-injected assets + + + + Task 2: Create static page cache and asset template functions + + summercms/src/theme/static/StaticPageCache.scala + summercms/src/theme/pebble/AssetFunctions.scala + summercms/src/theme/pebble/SummerThemeExtension.scala + + +Create `summercms/src/theme/static/` directory: + +**StaticPageCache.scala:** +```scala +package theme.static + +import com.stuart.zcaffeine.* +import zio.* +import scala.concurrent.duration.* + +/** Cached page HTML */ +case class CachedPage( + html: String, + cachedAt: java.time.Instant, + renderingMode: String +) + +/** + * Cache for static rendered pages using ZCaffeine. + */ +trait StaticPageCache: + /** Get cached page by key (usually URL path) */ + def get(key: String): UIO[Option[CachedPage]] + + /** Store page in cache */ + def put(key: String, page: CachedPage, ttl: Duration = 1.hour): UIO[Unit] + + /** Invalidate a specific page */ + def invalidate(key: String): UIO[Unit] + + /** Invalidate all pages matching a prefix */ + def invalidatePrefix(prefix: String): UIO[Unit] + + /** Invalidate all cached pages */ + def invalidateAll: UIO[Unit] + + /** Get cache statistics */ + def stats: UIO[CacheStats] + +case class CacheStats( + hitCount: Long, + missCount: Long, + size: Long +) + +object StaticPageCache: + /** Get cached page */ + def get(key: String): ZIO[StaticPageCache, Nothing, Option[CachedPage]] = + ZIO.serviceWithZIO[StaticPageCache](_.get(key)) + + /** Store page */ + def put(key: String, page: CachedPage, ttl: Duration = 1.hour): ZIO[StaticPageCache, Nothing, Unit] = + ZIO.serviceWithZIO[StaticPageCache](_.put(key, page, ttl)) + + /** Invalidate page */ + def invalidate(key: String): ZIO[StaticPageCache, Nothing, Unit] = + ZIO.serviceWithZIO[StaticPageCache](_.invalidate(key)) + + /** Invalidate all */ + def invalidateAll: ZIO[StaticPageCache, Nothing, Unit] = + ZIO.serviceWithZIO[StaticPageCache](_.invalidateAll) + + /** + * Live implementation using ZCaffeine. + * @param maxSize Maximum number of pages to cache + * @param defaultTtl Default time-to-live for cached pages + */ + def live(maxSize: Long = 10000, defaultTtl: Duration = 1.hour): ZLayer[Any, Nothing, StaticPageCache] = + ZLayer.scoped { + for + cache <- ZCaffeine.make[String, CachedPage] + .expireAfterWrite(java.time.Duration.ofMillis(defaultTtl.toMillis)) + .maximumSize(maxSize) + .recordStats() + .build + yield new StaticPageCache: + def get(key: String): UIO[Option[CachedPage]] = + cache.getIfPresent(key) + + def put(key: String, page: CachedPage, ttl: Duration): UIO[Unit] = + cache.put(key, page) + + def invalidate(key: String): UIO[Unit] = + cache.invalidate(key) + + def invalidatePrefix(prefix: String): UIO[Unit] = + // ZCaffeine doesn't support prefix invalidation directly + // Would need to iterate keys - for now, invalidate all + // This is a simplification; production might use Redis + cache.invalidateAll + + def invalidateAll: UIO[Unit] = + cache.invalidateAll + + def stats: UIO[CacheStats] = + for + st <- cache.stats + yield CacheStats( + hitCount = st.hitCount, + missCount = st.missCount, + size = 0 // ZCaffeine stats don't include size directly + ) + } +``` + +**AssetFunctions.scala:** +```scala +package theme.pebble + +import io.pebbletemplates.pebble.extension.Function +import io.pebbletemplates.pebble.template.{EvaluationContext, PebbleTemplate} +import theme.asset.{AssetService, AssetRegistry, AssetError} +import zio.* +import java.util.{Map as JMap, List as JList} +import scala.jdk.CollectionConverters.* + +/** + * Pebble function: {{ asset('path/to/file.png') }} + * Resolves asset path via AssetService. + */ +class AssetFunction(runtime: Runtime[AssetService]) extends Function: + override def getArgumentNames: JList[String] = List("path").asJava + + override def execute( + args: JMap[String, AnyRef], + self: PebbleTemplate, + context: EvaluationContext, + lineNumber: Int + ): AnyRef = + val path = args.get("path").toString + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run( + AssetService.resolveAsset(path).catchAll(_ => ZIO.succeed(path)) + ).getOrThrowFiberFailure() + } + +/** + * Pebble function: {{ assetLink('css/app.css') }} + * Generates CSS link tag(s) for an entry. + */ +class AssetLinkFunction(runtime: Runtime[AssetService]) extends Function: + override def getArgumentNames: JList[String] = List("entry").asJava + + override def execute( + args: JMap[String, AnyRef], + self: PebbleTemplate, + context: EvaluationContext, + lineNumber: Int + ): AnyRef = + val entry = args.get("entry").toString + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run( + AssetService.linkTag(entry).map(_.value).catchAll { err => + ZIO.succeed(s"") + } + ).getOrThrowFiberFailure() + } + +/** + * Pebble function: {{ assetScript('js/app.js') }} + * Generates JS script tag(s) for an entry. + */ +class AssetScriptFunction(runtime: Runtime[AssetService]) extends Function: + override def getArgumentNames: JList[String] = List("entry").asJava + + override def execute( + args: JMap[String, AnyRef], + self: PebbleTemplate, + context: EvaluationContext, + lineNumber: Int + ): AnyRef = + val entry = args.get("entry").toString + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run( + AssetService.scriptTag(entry).map(_.value).catchAll { err => + ZIO.succeed(s"") + } + ).getOrThrowFiberFailure() + } + +/** + * Pebble function: {{ vueHydrationScript() }} + * Generates Vue 3 hydration script for SPA mode. + */ +class VueHydrationFunction(runtime: Runtime[AssetService]) extends Function: + override def getArgumentNames: JList[String] = List.empty.asJava + + override def execute( + args: JMap[String, AnyRef], + self: PebbleTemplate, + context: EvaluationContext, + lineNumber: Int + ): AnyRef = + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run( + AssetService.vueHydrationScript(None).map(_.value) + ).getOrThrowFiberFailure() + } + +/** + * Pebble function: {{ injectedCss() }} + * Outputs all CSS injected via addCss(). + */ +class InjectedCssFunction(runtime: Runtime[AssetRegistry & AssetService]) extends Function: + override def getArgumentNames: JList[String] = List.empty.asJava + + override def execute( + args: JMap[String, AnyRef], + self: PebbleTemplate, + context: EvaluationContext, + lineNumber: Int + ): AnyRef = + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run( + for + injections <- AssetRegistry.getCss + tags <- ZIO.foreach(injections) { inj => + val attrs = inj.attributes.map { case (k, v) => s"""$k="$v"""" }.mkString(" ") + if inj.isExternal then + ZIO.succeed(s"""""") + else + AssetService.resolveAsset(inj.path) + .map(url => s"""""") + .catchAll(_ => ZIO.succeed(s"""""")) + } + yield tags.mkString("\n") + ).getOrThrowFiberFailure() + } + +/** + * Pebble function: {{ injectedJs() }} + * Outputs all JS injected via addJs(). + */ +class InjectedJsFunction(runtime: Runtime[AssetRegistry & AssetService]) extends Function: + override def getArgumentNames: JList[String] = List.empty.asJava + + override def execute( + args: JMap[String, AnyRef], + self: PebbleTemplate, + context: EvaluationContext, + lineNumber: Int + ): AnyRef = + Unsafe.unsafe { implicit unsafe => + runtime.unsafe.run( + for + injections <- AssetRegistry.getJs + tags <- ZIO.foreach(injections) { inj => + val attrs = inj.attributes.map { case (k, v) => s"""$k="$v"""" }.mkString(" ") + if inj.isExternal then + ZIO.succeed(s"""""") + else + AssetService.resolveAsset(inj.path) + .map(url => s"""""") + .catchAll(_ => ZIO.succeed(s"""""")) + } + yield tags.mkString("\n") + ).getOrThrowFiberFailure() + } +``` + +**Update SummerThemeExtension.scala** - replace existing file to add asset functions: + +```scala +package theme.pebble + +import io.pebbletemplates.pebble.extension.AbstractExtension +import io.pebbletemplates.pebble.extension.Function +import io.pebbletemplates.pebble.tokenParser.TokenParser +import theme.asset.{AssetService, AssetRegistry} +import zio.* +import java.util.{Map as JMap, List as JList} +import scala.jdk.CollectionConverters.* + +/** + * Pebble extension providing SummerCMS theme tags and functions. + * + * Tags: + * {% page %} - inject page content into layout + * {% partial 'name' %} - include partial template + * {% placeholder 'name' %} - define content slot + * {% put 'name' %}...{% endput %} - fill content slot + * + * Functions: + * {{ asset('path') }} - resolve asset URL + * {{ assetLink('entry') }} - generate CSS link tag + * {{ assetScript('entry') }} - generate JS script tag + * {{ vueHydrationScript() }} - Vue hydration script + * {{ injectedCss() }} - output addCss() injections + * {{ injectedJs() }} - output addJs() injections + * {{ csrfToken() }} - CSRF token for forms + */ +class SummerThemeExtension( + assetRuntime: Option[Runtime[AssetService & AssetRegistry]] = None +) extends AbstractExtension: + + override def getTokenParsers: JList[TokenParser] = + List[TokenParser]( + new PageTag(), + new PartialTag(), + new PlaceholderTag(), + new PutTag() + ).asJava + + override def getFunctions: JMap[String, Function] = + val baseFunctions = Map[String, Function]( + "csrfToken" -> new CsrfTokenFunction() + ) + + val assetFunctions = assetRuntime.map { runtime => + Map[String, Function]( + "asset" -> new AssetFunction(runtime.asInstanceOf[Runtime[AssetService]]), + "assetLink" -> new AssetLinkFunction(runtime.asInstanceOf[Runtime[AssetService]]), + "assetScript" -> new AssetScriptFunction(runtime.asInstanceOf[Runtime[AssetService]]), + "vueHydrationScript" -> new VueHydrationFunction(runtime.asInstanceOf[Runtime[AssetService]]), + "injectedCss" -> new InjectedCssFunction(runtime), + "injectedJs" -> new InjectedJsFunction(runtime) + ) + }.getOrElse(Map.empty) + + (baseFunctions ++ assetFunctions).asJava + +/** Generates CSRF tokens */ +class CsrfTokenFunction extends Function: + override def getArgumentNames: JList[String] = List.empty.asJava + + override def execute( + args: JMap[String, AnyRef], + self: io.pebbletemplates.pebble.template.PebbleTemplate, + context: io.pebbletemplates.pebble.template.EvaluationContext, + lineNumber: Int + ): AnyRef = + // For now return placeholder - real implementation needs session integration + Option(context.getVariable("_csrfToken")).map(_.toString).getOrElse("csrf-placeholder") +``` + + Run `./mill summercms.compile` - StaticPageCache, AssetFunctions, updated SummerThemeExtension compile + StaticPageCache with ZCaffeine, asset template functions (asset, assetLink, assetScript, vueHydrationScript, injectedCss, injectedJs) + + + + Task 3: Create PagePipeline, ThemeRoutes, and integrate with application + + summercms/src/theme/PagePipeline.scala + summercms/src/theme/ThemeRoutes.scala + summercms/src/theme/package.scala + summercms/src/api/Routes.scala + summercms/src/Main.scala + + +**PagePipeline.scala:** +```scala +package theme + +import theme.asset.{AssetService, AssetRegistry, AssetError} +import theme.static.{StaticPageCache, CachedPage} +import component.{ComponentManager, ComponentEnv, PageContext, PropertyValue} +import zio.* +import zio.http.* +import java.time.Instant + +/** Combined environment for page rendering */ +type PageEnv = ThemeService & AssetService & AssetRegistry & StaticPageCache & ComponentManager + +/** + * Complete page rendering pipeline. + * Handles: URL resolution, caching, component lifecycle, layout composition. + */ +object PagePipeline: + + /** + * Render a page from URL path. + * Follows the complete rendering lifecycle: + * 1. Check static cache (if applicable) + * 2. Resolve URL to page file + * 3. Parse front matter for config + * 4. Initialize page components + * 5. Run component onRun lifecycle + * 6. Render page content + * 7. Compose with layout + * 8. Cache if static mode + */ + def render( + urlPath: String, + context: Map[String, Any] = Map.empty + ): ZIO[PageEnv, ThemeError | AssetError, Response] = + for + // Get theme and determine rendering mode + theme <- ThemeService.getActive.flatMap { + case Some(t) => ZIO.succeed(t) + case None => ZIO.fail(ThemeError.ThemeNotFound("no active theme")) + } + + // Convert URL path to page path (e.g., "/" -> "home", "/blog/post" -> "blog/post") + pagePath = urlPathToPagePath(urlPath) + + // Get page config to determine rendering mode + pageConfig <- ZIO.serviceWithZIO[ThemeService](_.getPageConfig(pagePath)) + renderingMode = pageConfig.effectiveRendering(theme.rendering) + + // Check cache for static pages + cached <- if renderingMode == RenderingMode.Static then + StaticPageCache.get(urlPath) + else + ZIO.none + + response <- cached match + case Some(page) => + // Cache hit - return cached HTML + ZIO.succeed(Response.html(page.html)) + + case None => + // Cache miss - render page + for + // Clear asset registry for this request + _ <- AssetRegistry.getCss.flatMap(_ => ZIO.unit) // Trigger initialization + registry <- ZIO.service[AssetRegistry] + _ <- registry.clear + + // Build rendering context + renderContext = buildRenderContext(theme, pageConfig, urlPath, context) + + // Initialize components declared on page + // (Full component integration in Phase 3 - for now, empty) + + // Render the page + html <- ThemeService.renderPage(pagePath, renderContext) + + // Cache if static mode + _ <- ZIO.when(renderingMode == RenderingMode.Static) { + val cachedPage = CachedPage( + html = html, + cachedAt = Instant.now(), + renderingMode = "static" + ) + StaticPageCache.put(urlPath, cachedPage) + } + yield Response.html(html) + yield response + + /** Convert URL path to page path */ + private def urlPathToPagePath(urlPath: String): String = + val cleaned = urlPath.stripPrefix("/").stripSuffix("/") + if cleaned.isEmpty then "home" + else cleaned + + /** Build context for template rendering */ + private def buildRenderContext( + theme: ThemeConfig, + pageConfig: PageConfig, + urlPath: String, + extraContext: Map[String, Any] + ): Map[String, Any] = + Map( + "url" -> urlPath, + "locale" -> "en", // TODO: i18n integration + "_csrfToken" -> "csrf-placeholder" // TODO: session integration + ) ++ extraContext + + /** + * Handle page request. + * Catches errors and returns appropriate HTTP responses. + */ + def handleRequest(request: Request): ZIO[PageEnv, Nothing, Response] = + val urlPath = request.url.path.toString + render(urlPath, Map("request" -> request)).catchAll { + case ThemeError.PageNotFound(_, _) => + ZIO.succeed(Response.html(notFoundHtml(urlPath)).status(Status.NotFound)) + case ThemeError.ThemeNotFound(_) => + ZIO.succeed(Response.html(errorHtml("No theme configured")).status(Status.InternalServerError)) + case err: ThemeError => + ZIO.succeed(Response.html(errorHtml(err.message)).status(Status.InternalServerError)) + case err: AssetError => + ZIO.succeed(Response.html(errorHtml(err.message)).status(Status.InternalServerError)) + } + + private def notFoundHtml(path: String): String = + s""" + +404 Not Found + +

404 Not Found

+

The page $path could not be found.

+ +""" + + private def errorHtml(message: String): String = + s""" + +Error + +

Error

+

$message

+ +""" +``` + +**ThemeRoutes.scala:** +```scala +package theme + +import theme.asset.AssetService +import theme.static.StaticPageCache +import component.ComponentManager +import zio.* +import zio.http.* +import java.nio.file.{Files, Path} + +/** Routes for theme asset serving and page rendering */ +object ThemeRoutes: + + /** Routes for serving theme static assets */ + def assetRoutes: Routes[ThemeService, Response] = + Routes( + // Serve theme assets: /themes/{code}/assets/{path} + Method.GET / "themes" / string("code") / "assets" / trailing -> + handler { (code: String, path: Path, req: Request) => + serveThemeAsset(code, path) + } + ) + + /** Catch-all route for page rendering */ + def pageRoutes: Routes[PageEnv, Response] = + Routes( + // Catch-all for CMS pages + Method.GET / trailing -> + handler { (path: Path, req: Request) => + PagePipeline.handleRequest(req) + } + ) + + /** Serve a static asset from theme directory */ + private def serveThemeAsset(themeCode: String, assetPath: Path): ZIO[ThemeService, Nothing, Response] = + (for + theme <- ThemeService.getActive.flatMap { + case Some(t) if t.code == themeCode => ZIO.succeed(t) + case Some(_) => ZIO.fail("Theme mismatch") + case None => ZIO.fail("No active theme") + } + + // Resolve asset path (prevent directory traversal) + relativePath = assetPath.toString.stripPrefix("/") + fullPath = theme.path.resolve("assets").resolve(relativePath).normalize() + + // Security: ensure path is within theme assets directory + assetsDir = theme.path.resolve("assets").normalize() + _ <- ZIO.fail("Invalid path").when(!fullPath.startsWith(assetsDir)) + + // Check if file exists + exists <- ZIO.attemptBlocking(Files.exists(fullPath) && Files.isRegularFile(fullPath)).orDie + _ <- ZIO.fail("Not found").when(!exists) + + // Determine content type + contentType = guessContentType(fullPath.getFileName.toString) + + // Read and return file + content <- ZIO.attemptBlocking(Files.readAllBytes(fullPath)).orDie + yield Response( + status = Status.Ok, + headers = Headers(Header.ContentType(contentType)), + body = Body.fromArray(content) + )).catchAll { err => + ZIO.succeed(Response.status(Status.NotFound)) + } + + private def guessContentType(filename: String): MediaType = + val ext = filename.lastIndexOf('.') match + case -1 => "" + case i => filename.substring(i + 1).toLowerCase + ext match + case "css" => MediaType.text.css + case "js" => MediaType.application.javascript + case "json" => MediaType.application.json + case "png" => MediaType.image.png + case "jpg" | "jpeg" => MediaType.image.jpeg + case "gif" => MediaType.image.gif + case "svg" => MediaType.image.`svg+xml` + case "woff" => MediaType.application.`font-woff` + case "woff2" => MediaType.application.`font-woff` + case "ttf" => MediaType.application.`font-sfnt` + case "eot" => MediaType.application.`vnd.ms-fontobject` + case "ico" => MediaType.image.`x-icon` + case "html" | "htm" => MediaType.text.html + case "txt" => MediaType.text.plain + case _ => MediaType.application.`octet-stream` +``` + +**package.scala:** +```scala +package object theme: + import zio.* + import theme.asset.{AssetService, AssetRegistry} + import theme.static.StaticPageCache + + /** Combined layer for all theme services */ + type ThemeServices = ThemeService & ThemeLoader & AssetService & AssetRegistry & StaticPageCache + + /** + * Create theme services layer. + * @param devMode Whether in development mode (use Vite dev server) + */ + def themeLayer(devMode: Boolean): ZLayer[Any, ThemeError, ThemeServices] = + val loaderLayer = ThemeLoader.live + val themeServiceLayer = loaderLayer >>> ThemeService.default + + // Asset layer needs active theme - we'll create it dynamically per request + // For now, provide a basic layer + val assetLayer = loaderLayer >>> ZLayer.fromZIO { + for + loader <- ZIO.service[ThemeLoader] + themes <- loader.discover(ThemeLoader.defaultThemesDir) + theme = themes.headOption + service <- theme match + case Some(t) => AssetService.make(t, devMode) + case None => ZIO.succeed(new theme.asset.AssetServiceLive( + ThemeConfig("default", ""), + theme.asset.ViteManifest.empty, + devMode, + "http://localhost:5173" + ).asInstanceOf[AssetService]) + yield service + }.mapError(e => ThemeError.ThemeNotFound(e.message)) + + val registryLayer = AssetRegistry.live + val cacheLayer = StaticPageCache.live() + + loaderLayer ++ themeServiceLayer ++ assetLayer ++ registryLayer ++ cacheLayer + +// Note: AssetServiceLive needs to be accessible +package theme.asset { + // Make AssetServiceLive package-private accessible + private[theme] class AssetServiceLive( + theme: ThemeConfig, + manifest: ViteManifest, + devMode: Boolean, + viteDevUrl: String + ) extends AssetService: + private val publicPath = theme.assets.publicPath.getOrElse(s"/themes/${theme.code}/assets/") + + def isDevelopment: UIO[Boolean] = ZIO.succeed(devMode) + + def scriptTag(entry: String): IO[AssetError, AssetHtml] = + if devMode then + ZIO.succeed(AssetHtml( + s""" +""")) + else + manifest.get(entry) match + case Some(e) => + val scripts = manifest.getJsForEntry(entry).map { file => + s"""""" + } + ZIO.succeed(AssetHtml(scripts.mkString("\n"))) + case None => + ZIO.fail(AssetError.AssetNotFound(entry)) + + def linkTag(entry: String): IO[AssetError, AssetHtml] = + if devMode then + ZIO.succeed(AssetHtml(s"""""")) + else + val cssFiles = manifest.getCssForEntry(entry) + if cssFiles.isEmpty then + manifest.get(entry).map(_.file) match + case Some(file) => + ZIO.succeed(AssetHtml(s"""""")) + case None => + ZIO.fail(AssetError.AssetNotFound(entry)) + else + ZIO.succeed(AssetHtml(cssFiles.map(f => s"""""").mkString("\n"))) + + def resolveAsset(path: String): IO[AssetError, String] = + if devMode then ZIO.succeed(s"$viteDevUrl/$path") + else ZIO.succeed(manifest.get(path).map(_.file).getOrElse(path)).map(f => s"$publicPath$f") + + def vueHydrationScript(vueEntry: Option[String]): UIO[AssetHtml] = + val entry = vueEntry.orElse(theme.vue.entry).getOrElse("js/app.js") + if devMode then + ZIO.succeed(AssetHtml(s"""""")) + else + ZIO.succeed(AssetHtml(s"""""")) +} +``` + +**Update api/Routes.scala:** + +Read existing file and add theme routes. The key is to: +1. Import theme routes +2. Combine with existing routes +3. Theme page routes should be LAST (catch-all) + +```scala +package api + +import zio.http.* +import component.{ComponentRoutes, componentLayer} +import theme.{ThemeRoutes, themeLayer, PageEnv} +import zio.* + +object Routes: + /** + * Combined routes for the application. + * Order matters: specific routes first, catch-all page routes last. + */ + def routes(devMode: Boolean): Routes[PageEnv, Response] = + // API and health routes (highest priority) + HealthRoutes.routes ++ + LandingRoutes.routes ++ + // Component HTMX routes + ComponentRoutes.routes ++ + // Theme asset serving + ThemeRoutes.assetRoutes ++ + // CMS page routes (catch-all - lowest priority) + ThemeRoutes.pageRoutes +``` + +**Update Main.scala:** + +Read existing file and update to include theme layer: + +```scala +package summercms + +import zio.* +import zio.http.* +import zio.config.typesafe.TypesafeConfigProvider + +import api.Routes +import _root_.config.{AppConfig as SummerConfig} +import db.QuillContext +import plugin.{PluginManager, PluginDiscovery, pluginLayer} +import component.{ComponentManager, componentLayer} +import theme.{ThemeService, ThemeLoader, themeLayer} + +object Main extends ZIOAppDefault { + + private val banner: String = + """ + | + | | . + | `. * | .' + | `. ._|_* .' . + | . * .' `. * + | -------| |------- + | . *`.___.' * . + | .' |* `. * + | .' * | . `. + | . | + | + | S U M M E R C M S + |""".stripMargin + + override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] = + Runtime.setConfigProvider(TypesafeConfigProvider.fromResourcePath()) + + private val startupLogic: ZIO[Any, Any, Nothing] = + for { + cfg <- ZIO.config[SummerConfig](SummerConfig.config) + _ <- Console.printLine(banner) + _ <- Console.printLine(s"Starting on port ${cfg.server.port}...") + _ <- Console.printLine(s"Development mode: ${cfg.server.isDevelopment}") + _ <- Console.printLine("") + + // Determine if in dev mode + devMode = cfg.server.isDevelopment + + // Create combined layer for all services + combinedLayer = pluginLayer ++ componentLayer ++ themeLayer(devMode).orDie + + res <- (for { + // Initialize plugin system + _ <- Console.printLine("Loading plugins...") + _ <- PluginManager.loadPlugins(PluginDiscovery.defaultPluginsDir) + .catchAll(e => Console.printLine(s"Plugin loading warning: ${e.message}").as(())) + _ <- PluginManager.bootAll + .catchAll(e => Console.printLine(s"Plugin boot warning: ${e.message}").as(())) + plugins <- PluginManager.listPlugins + _ <- Console.printLine(s"Loaded ${plugins.count(_._2.isActive)} plugin(s)") + + // Initialize component system + components <- ComponentManager.listComponents + _ <- Console.printLine(s"Registered ${components.size} component(s)") + + // Initialize theme system + theme <- ThemeService.getActive + _ <- theme match + case Some(t) => Console.printLine(s"Active theme: ${t.name} (${t.rendering})") + case None => Console.printLine("Warning: No theme found in themes/ directory") + + _ <- Console.printLine("") + _ <- Console.printLine(s"Server ready at http://localhost:${cfg.server.port}") + + // Start server with all routes + res <- Server.serve(Routes.routes(devMode)).provide( + Server.defaultWithPort(cfg.server.port), + QuillContext.dataSourceLayer + ) + } yield res).provide(combinedLayer) + } yield res + + override def run: ZIO[Any, Any, Any] = startupLogic +} +``` + +Note: The exact integration depends on existing file contents. Key changes: +1. Import theme package +2. Add `isDevelopment` config check (may need to add to AppConfig) +3. Create themeLayer with devMode +4. Combine layers: pluginLayer ++ componentLayer ++ themeLayer +5. Initialize theme system and report active theme +6. Pass devMode to Routes.routes() + +If AppConfig doesn't have isDevelopment, add it: +```scala +// In config/AppConfig.scala +case class ServerConfig( + port: Int, + isDevelopment: Boolean = true // Add this +) +``` + +And in application.conf: +```hocon +server { + port = 8080 + is-development = true // Add this for dev mode +} +``` +
+ +Run `./mill summercms.compile` - all files compile. + +Run `./mill summercms.run` - server starts and reports: +- "Active theme: Test Theme (hybrid)" (if test theme exists from 04-01) +- Or "Warning: No theme found in themes/ directory" + +Test page rendering: +```bash +curl http://localhost:8080/ +# Should return rendered home page or 404 if no theme + +curl http://localhost:8080/themes/test-theme/assets/css/app.css +# Should serve CSS file or 404 if not exists +``` + + PagePipeline handles complete rendering flow with caching, ThemeRoutes serves assets and pages, application integrates all theme services +
+ +
+ + +1. `./mill summercms.compile` succeeds with all new types +2. `./mill summercms.run` starts server with theme system initialized +3. ViteManifest parses production manifest.json correctly +4. AssetService resolves assets in both dev and prod modes +5. StaticPageCache caches rendered pages with ZCaffeine +6. Asset template functions output correct HTML +7. ThemeRoutes serves static assets from theme directory +8. PagePipeline renders pages with layout composition and caching +9. GET / returns rendered home page (or 404 if no theme) + + + +- ViteManifest parses manifest entries with file paths and CSS/imports +- AssetService generates correct script/link tags for dev and prod +- AssetRegistry tracks addCss/addJs injections +- StaticPageCache provides ZCaffeine-based page caching +- Template functions: asset, assetLink, assetScript, vueHydrationScript, injectedCss, injectedJs +- ThemeRoutes serves /themes/{code}/assets/* with proper content types +- PagePipeline handles complete rendering lifecycle +- Server starts with theme system and reports active theme + + + +After completion, create `.planning/phases/04-theme-engine/04-02-SUMMARY.md` +