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
1068 lines
37 KiB
Markdown
1068 lines
37 KiB
Markdown
---
|
|
phase: 04-theme-engine
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- build.mill
|
|
- summercms/src/theme/ThemeConfig.scala
|
|
- summercms/src/theme/PageConfig.scala
|
|
- summercms/src/theme/RenderingMode.scala
|
|
- summercms/src/theme/ThemeError.scala
|
|
- summercms/src/theme/ThemeLoader.scala
|
|
- summercms/src/theme/ThemeService.scala
|
|
- summercms/src/theme/pebble/SummerThemeExtension.scala
|
|
- summercms/src/theme/pebble/PartialTag.scala
|
|
- summercms/src/theme/pebble/PageTag.scala
|
|
- summercms/src/theme/pebble/PlaceholderTag.scala
|
|
- summercms/src/theme/pebble/PutTag.scala
|
|
autonomous: true
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Developer can create a theme with theme.yaml, layouts/, pages/, and partials/ directories"
|
|
- "Pages declare their layout in front matter YAML"
|
|
- "Layouts use {% page %} to inject page content"
|
|
- "{% partial 'name' %} includes partial templates with variable passing"
|
|
- "{% placeholder 'name' %} defines named content blocks"
|
|
- "{% put 'name' %} fills placeholder blocks from pages"
|
|
- "Theme rendering mode is configured in theme.yaml"
|
|
artifacts:
|
|
- path: "summercms/src/theme/ThemeService.scala"
|
|
provides: "Theme rendering service"
|
|
exports: ["ThemeService"]
|
|
- path: "summercms/src/theme/ThemeConfig.scala"
|
|
provides: "Theme metadata from theme.yaml"
|
|
contains: "case class ThemeConfig"
|
|
- path: "summercms/src/theme/PageConfig.scala"
|
|
provides: "Page front matter parsing"
|
|
contains: "case class PageConfig"
|
|
- path: "summercms/src/theme/pebble/SummerThemeExtension.scala"
|
|
provides: "Custom Pebble tags for theme rendering"
|
|
contains: "class SummerThemeExtension"
|
|
key_links:
|
|
- from: "summercms/src/theme/ThemeService.scala"
|
|
to: "summercms/src/theme/ThemeLoader.scala"
|
|
via: "loads theme configuration"
|
|
pattern: "ThemeLoader\\.load"
|
|
- from: "summercms/src/theme/ThemeService.scala"
|
|
to: "io.pebbletemplates.pebble.PebbleEngine"
|
|
via: "renders templates"
|
|
pattern: "engine\\.getTemplate"
|
|
- from: "summercms/src/theme/pebble/PartialTag.scala"
|
|
to: "ThemeService"
|
|
via: "renders partial templates"
|
|
pattern: "renderPartial"
|
|
---
|
|
|
|
<objective>
|
|
Create the theme loading and layout composition system for SummerCMS.
|
|
|
|
Purpose: Themes are the visual layer that defines how pages look. This plan establishes theme discovery, configuration parsing (theme.yaml), layout composition with the {% page %} tag, partials with {% partial %}, and the placeholder system ({% placeholder %}/{% put %}). Asset management and Vue integration follow in 04-02.
|
|
|
|
Output: ThemeService, ThemeConfig, PageConfig, ThemeLoader, custom Pebble tags (partial, page, placeholder, put)
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
|
|
@/home/jin/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/04-theme-engine/04-RESEARCH.md
|
|
@.planning/phases/04-theme-engine/04-CONTEXT.md
|
|
@build.mill
|
|
@summercms/src/component/TemplateService.scala
|
|
@summercms/src/component/SummerComponent.scala
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Add theme engine dependencies to build.mill</name>
|
|
<files>build.mill</files>
|
|
<action>
|
|
Add the following dependencies to build.mill mvnDeps. Note that Pebble 4.1.1 may already exist from Phase 3 - check first and only add if missing:
|
|
|
|
```scala
|
|
// Theme engine - Pebble with Scala wrapper (if not already present)
|
|
mvn"io.pebbletemplates:pebble:4.1.1",
|
|
mvn"com.sfxcode.templating::pebble-scala:1.0.2",
|
|
|
|
// Theme engine - ZIO caching for static pages
|
|
mvn"com.stuart::zcaffeine:0.9.10",
|
|
```
|
|
|
|
The dependencies should be added after the existing component system dependencies.
|
|
|
|
Note:
|
|
- pebble-scala:1.0.2 provides native Scala collection support for Pebble
|
|
- ZCaffeine 0.9.10 will be used in 04-02 for static page caching, but add it now to avoid build changes later
|
|
- circe-yaml and circe-generic should already be present from Phase 2/3 for YAML parsing
|
|
</action>
|
|
<verify>Run `./mill summercms.compile` - dependencies resolve successfully</verify>
|
|
<done>build.mill contains Pebble 4.1.1, pebble-scala 1.0.2, ZCaffeine 0.9.10 dependencies</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create theme configuration and error types</name>
|
|
<files>
|
|
summercms/src/theme/RenderingMode.scala
|
|
summercms/src/theme/ThemeError.scala
|
|
summercms/src/theme/ThemeConfig.scala
|
|
summercms/src/theme/PageConfig.scala
|
|
</files>
|
|
<action>
|
|
Create `summercms/src/theme/` directory with these files:
|
|
|
|
**RenderingMode.scala:**
|
|
```scala
|
|
package theme
|
|
|
|
import io.circe.*
|
|
|
|
/** Theme/page rendering mode - determines how frontend is delivered */
|
|
enum RenderingMode:
|
|
case Spa // Server renders HTML, Vue 3 hydrates fully on client
|
|
case Hybrid // Per-page choice, HTMX for interactions (default)
|
|
case Static // Pre-generate or cache on first request
|
|
|
|
object RenderingMode:
|
|
/** Parse from string (case-insensitive) */
|
|
def fromString(s: String): Option[RenderingMode] =
|
|
s.toLowerCase match
|
|
case "spa" => Some(Spa)
|
|
case "hybrid" => Some(Hybrid)
|
|
case "static" => Some(Static)
|
|
case _ => None
|
|
|
|
given Decoder[RenderingMode] = Decoder.decodeString.emap { s =>
|
|
fromString(s).toRight(s"Unknown rendering mode: $s. Expected: spa, hybrid, or static")
|
|
}
|
|
|
|
given Encoder[RenderingMode] = Encoder.encodeString.contramap(_.toString.toLowerCase)
|
|
```
|
|
|
|
**ThemeError.scala:**
|
|
```scala
|
|
package theme
|
|
|
|
import java.nio.file.Path
|
|
|
|
/** Theme system error ADT */
|
|
enum ThemeError:
|
|
case ThemeNotFound(name: String)
|
|
case ThemeConfigMissing(themePath: Path)
|
|
case ThemeConfigInvalid(themePath: Path, message: String)
|
|
case LayoutNotFound(themeName: String, layoutName: String)
|
|
case PageNotFound(themeName: String, pagePath: String)
|
|
case PartialNotFound(themeName: String, partialName: String)
|
|
case TemplateRenderError(template: String, cause: Throwable)
|
|
case FrontMatterParseError(pagePath: Path, message: String)
|
|
case PlaceholderNotDefined(name: String)
|
|
case CircularPartialInclude(partialName: String, stack: List[String])
|
|
|
|
def message: String = this match
|
|
case ThemeNotFound(n) => s"Theme not found: $n"
|
|
case ThemeConfigMissing(p) => s"theme.yaml not found at: $p"
|
|
case ThemeConfigInvalid(p, m) => s"Invalid theme.yaml at $p: $m"
|
|
case LayoutNotFound(t, l) => s"Layout '$l' not found in theme '$t'"
|
|
case PageNotFound(t, p) => s"Page '$p' not found in theme '$t'"
|
|
case PartialNotFound(t, p) => s"Partial '$p' not found in theme '$t'"
|
|
case TemplateRenderError(t, c) => s"Failed to render template '$t': ${c.getMessage}"
|
|
case FrontMatterParseError(p, m) => s"Invalid front matter in '$p': $m"
|
|
case PlaceholderNotDefined(n) => s"Placeholder '$n' not defined in layout"
|
|
case CircularPartialInclude(n, s) => s"Circular partial include detected: $n (stack: ${s.mkString(" -> ")})"
|
|
```
|
|
|
|
**ThemeConfig.scala:**
|
|
```scala
|
|
package theme
|
|
|
|
import io.circe.*
|
|
import io.circe.generic.semiauto.*
|
|
import java.nio.file.Path
|
|
|
|
/** Asset configuration in theme.yaml */
|
|
case class AssetConfig(
|
|
manifest: Option[String] = None, // Path to Vite manifest.json relative to theme
|
|
publicPath: Option[String] = None // Public URL path for assets
|
|
)
|
|
|
|
object AssetConfig:
|
|
given Decoder[AssetConfig] = deriveDecoder[AssetConfig]
|
|
|
|
/** Vue configuration in theme.yaml */
|
|
case class VueConfig(
|
|
entry: Option[String] = None, // Vue entry point (e.g., "js/app.js")
|
|
hydrateOnLoad: Boolean = true // Whether to hydrate immediately on page load
|
|
)
|
|
|
|
object VueConfig:
|
|
given Decoder[VueConfig] = deriveDecoder[VueConfig]
|
|
|
|
/**
|
|
* Theme configuration parsed from theme.yaml
|
|
*
|
|
* Example theme.yaml:
|
|
* ```yaml
|
|
* name: My Theme
|
|
* description: A modern theme for SummerCMS
|
|
* author: Golem15
|
|
* version: 1.0.0
|
|
* rendering: hybrid
|
|
* assets:
|
|
* manifest: .vite/manifest.json
|
|
* publicPath: /themes/my-theme/assets/
|
|
* vue:
|
|
* entry: js/app.js
|
|
* hydrateOnLoad: true
|
|
* ```
|
|
*/
|
|
case class ThemeConfig(
|
|
name: String,
|
|
description: String = "",
|
|
author: Option[String] = None,
|
|
version: Option[String] = None,
|
|
rendering: RenderingMode = RenderingMode.Hybrid,
|
|
assets: AssetConfig = AssetConfig(),
|
|
vue: VueConfig = VueConfig(),
|
|
// Runtime - set by ThemeLoader, not from YAML
|
|
path: Path = Path.of("."),
|
|
code: String = "" // Theme directory name (e.g., "my-theme")
|
|
)
|
|
|
|
object ThemeConfig:
|
|
given Decoder[ThemeConfig] = deriveDecoder[ThemeConfig]
|
|
|
|
/** Create config with runtime path info */
|
|
def withPath(config: ThemeConfig, themePath: Path, themeCode: String): ThemeConfig =
|
|
config.copy(path = themePath, code = themeCode)
|
|
```
|
|
|
|
**PageConfig.scala:**
|
|
```scala
|
|
package theme
|
|
|
|
import io.circe.*
|
|
import io.circe.generic.semiauto.*
|
|
import io.circe.yaml.parser
|
|
import zio.*
|
|
import java.nio.file.Path
|
|
|
|
/**
|
|
* Page configuration from front matter.
|
|
*
|
|
* Example page front matter:
|
|
* ```yaml
|
|
* ---
|
|
* layout: default
|
|
* title: Welcome
|
|
* description: Home page
|
|
* rendering: spa
|
|
* ---
|
|
* Page content here...
|
|
* ```
|
|
*/
|
|
case class PageConfig(
|
|
layout: String = "default",
|
|
title: Option[String] = None,
|
|
description: Option[String] = None,
|
|
rendering: Option[RenderingMode] = None, // Override theme default
|
|
url: Option[String] = None, // Explicit URL (otherwise derived from path)
|
|
// Components declared on this page (Phase 3 integration)
|
|
components: Map[String, Map[String, Any]] = Map.empty,
|
|
// Custom variables available in template
|
|
variables: Map[String, Any] = Map.empty
|
|
):
|
|
/** Get effective rendering mode, falling back to theme default */
|
|
def effectiveRendering(themeDefault: RenderingMode): RenderingMode =
|
|
rendering.getOrElse(themeDefault)
|
|
|
|
object PageConfig:
|
|
// Note: Custom decoder because components/variables need special handling
|
|
given Decoder[PageConfig] = Decoder.instance { cursor =>
|
|
for
|
|
layout <- cursor.getOrElse[String]("layout")("default")
|
|
title <- cursor.get[Option[String]]("title")
|
|
description <- cursor.get[Option[String]]("description")
|
|
rendering <- cursor.get[Option[RenderingMode]]("rendering")
|
|
url <- cursor.get[Option[String]]("url")
|
|
yield PageConfig(
|
|
layout = layout,
|
|
title = title,
|
|
description = description,
|
|
rendering = rendering,
|
|
url = url,
|
|
components = Map.empty, // Parsed separately if needed
|
|
variables = Map.empty // Parsed separately if needed
|
|
)
|
|
}
|
|
|
|
/** Default config for pages without front matter */
|
|
val default: PageConfig = PageConfig()
|
|
|
|
/**
|
|
* Parse front matter from page content.
|
|
* Returns (PageConfig, pageBody) tuple.
|
|
*
|
|
* Front matter is delimited by --- at start and end:
|
|
* ---
|
|
* key: value
|
|
* ---
|
|
* Body content here
|
|
*/
|
|
def parseFrontMatter(content: String, path: Path): IO[ThemeError, (PageConfig, String)] =
|
|
ZIO.attempt {
|
|
val lines = content.linesIterator.toList
|
|
if lines.headOption.map(_.trim) != Some("---") then
|
|
// No front matter - use defaults
|
|
(PageConfig.default, content)
|
|
else
|
|
// Find closing ---
|
|
val afterFirst = lines.drop(1)
|
|
val closingIndex = afterFirst.indexWhere(_.trim == "---")
|
|
if closingIndex < 0 then
|
|
// No closing --- - treat entire content as body
|
|
(PageConfig.default, content)
|
|
else
|
|
val yamlLines = afterFirst.take(closingIndex)
|
|
val bodyLines = afterFirst.drop(closingIndex + 1)
|
|
val yamlStr = yamlLines.mkString("\n")
|
|
val body = bodyLines.mkString("\n")
|
|
|
|
parser.parse(yamlStr)
|
|
.flatMap(_.as[PageConfig])
|
|
.fold(
|
|
err => throw new RuntimeException(err.getMessage),
|
|
config => (config, body)
|
|
)
|
|
}.mapError(e => ThemeError.FrontMatterParseError(path, e.getMessage))
|
|
```
|
|
</action>
|
|
<verify>Run `./mill summercms.compile` - all theme types compile without errors</verify>
|
|
<done>RenderingMode enum, ThemeError ADT, ThemeConfig with asset/vue config, PageConfig with front matter parsing</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Create custom Pebble tags and ThemeService</name>
|
|
<files>
|
|
summercms/src/theme/pebble/SummerThemeExtension.scala
|
|
summercms/src/theme/pebble/PartialTag.scala
|
|
summercms/src/theme/pebble/PageTag.scala
|
|
summercms/src/theme/pebble/PlaceholderTag.scala
|
|
summercms/src/theme/pebble/PutTag.scala
|
|
summercms/src/theme/ThemeLoader.scala
|
|
summercms/src/theme/ThemeService.scala
|
|
</files>
|
|
<action>
|
|
Create `summercms/src/theme/pebble/` directory with custom Pebble tags:
|
|
|
|
**PageTag.scala:**
|
|
```scala
|
|
package theme.pebble
|
|
|
|
import io.pebbletemplates.pebble.extension.NodeVisitor
|
|
import io.pebbletemplates.pebble.node.{AbstractRenderableNode, RenderableNode}
|
|
import io.pebbletemplates.pebble.template.{EvaluationContextImpl, PebbleTemplateImpl}
|
|
import io.pebbletemplates.pebble.tokenParser.TokenParser
|
|
import io.pebbletemplates.pebble.lexer.Token
|
|
import io.pebbletemplates.pebble.parser.Parser
|
|
import java.io.Writer
|
|
|
|
/**
|
|
* {% page %} tag - injects page content into layout.
|
|
* Used in layouts to mark where page body should appear.
|
|
*/
|
|
class PageTag extends TokenParser:
|
|
override def getTag: String = "page"
|
|
|
|
override def parse(token: Token, parser: Parser): RenderableNode =
|
|
val stream = parser.getStream
|
|
stream.expect(classOf[Token.Type], "TAG_END")
|
|
PageNode(token.getLineNumber)
|
|
|
|
class PageNode(lineNumber: Int) extends AbstractRenderableNode(lineNumber):
|
|
override def render(self: PebbleTemplateImpl, writer: Writer, context: EvaluationContextImpl): Unit =
|
|
// Page content is stored in context by ThemeService before layout rendering
|
|
val pageContent = Option(context.getVariable("__PAGE_CONTENT__"))
|
|
.map(_.toString)
|
|
.getOrElse("")
|
|
writer.write(pageContent)
|
|
|
|
override def accept(visitor: NodeVisitor): Unit = visitor.visit(this)
|
|
```
|
|
|
|
**PartialTag.scala:**
|
|
```scala
|
|
package theme.pebble
|
|
|
|
import io.pebbletemplates.pebble.extension.NodeVisitor
|
|
import io.pebbletemplates.pebble.node.{AbstractRenderableNode, RenderableNode}
|
|
import io.pebbletemplates.pebble.node.expression.Expression
|
|
import io.pebbletemplates.pebble.template.{EvaluationContextImpl, PebbleTemplateImpl, ScopeChain}
|
|
import io.pebbletemplates.pebble.tokenParser.TokenParser
|
|
import io.pebbletemplates.pebble.lexer.Token
|
|
import io.pebbletemplates.pebble.parser.Parser
|
|
import java.io.{StringWriter, Writer}
|
|
import java.util.{Map as JMap}
|
|
import scala.jdk.CollectionConverters.*
|
|
|
|
/**
|
|
* {% partial 'name' %} tag - includes a partial template.
|
|
* Supports variable passing:
|
|
* {% partial 'card' title='Hello' count=5 %}
|
|
* {% partial 'card' with cardData %}
|
|
*/
|
|
class PartialTag extends TokenParser:
|
|
override def getTag: String = "partial"
|
|
|
|
override def parse(token: Token, parser: Parser): RenderableNode =
|
|
val stream = parser.getStream
|
|
val lineNumber = token.getLineNumber
|
|
|
|
// Parse partial name (string expression)
|
|
val partialName = parser.getExpressionParser.parseExpression()
|
|
|
|
// Parse optional arguments
|
|
var namedArgs: Map[String, Expression[?]] = Map.empty
|
|
var withExpr: Option[Expression[?]] = None
|
|
|
|
while stream.current().test(classOf[Token.Type], "NAME") do
|
|
val argName = stream.current().getValue
|
|
stream.next()
|
|
|
|
if argName == "with" then
|
|
// {% partial 'name' with contextObject %}
|
|
withExpr = Some(parser.getExpressionParser.parseExpression())
|
|
else
|
|
// Named argument: key=value
|
|
stream.expect(classOf[Token.Type], "PUNCTUATION", "=")
|
|
val value = parser.getExpressionParser.parseExpression()
|
|
namedArgs = namedArgs + (argName -> value)
|
|
|
|
stream.expect(classOf[Token.Type], "TAG_END")
|
|
PartialNode(lineNumber, partialName, namedArgs.asJava, withExpr.orNull)
|
|
|
|
class PartialNode(
|
|
lineNumber: Int,
|
|
partialNameExpr: Expression[?],
|
|
namedArgs: JMap[String, Expression[?]],
|
|
withExpr: Expression[?]
|
|
) extends AbstractRenderableNode(lineNumber):
|
|
|
|
override def render(self: PebbleTemplateImpl, writer: Writer, context: EvaluationContextImpl): Unit =
|
|
val partialName = partialNameExpr.evaluate(self, context).toString
|
|
|
|
// Get theme path from context
|
|
val themePath = Option(context.getVariable("__THEME_PATH__"))
|
|
.map(_.toString)
|
|
.getOrElse("themes/default")
|
|
|
|
// Build partial context
|
|
val partialContext = new java.util.HashMap[String, AnyRef]()
|
|
|
|
// Copy existing variables
|
|
Option(context.getScopeChain).foreach { sc =>
|
|
sc.getKeys.forEach { key =>
|
|
partialContext.put(key, sc.get(key))
|
|
}
|
|
}
|
|
|
|
// Add "with" object if specified
|
|
if withExpr != null then
|
|
val withObj = withExpr.evaluate(self, context)
|
|
withObj match
|
|
case map: JMap[?, ?] =>
|
|
map.forEach { (k, v) =>
|
|
partialContext.put(k.toString, v.asInstanceOf[AnyRef])
|
|
}
|
|
case _ =>
|
|
// If not a map, make it available as "it"
|
|
partialContext.put("it", withObj.asInstanceOf[AnyRef])
|
|
|
|
// Add named arguments (override with object)
|
|
namedArgs.forEach { (key, expr) =>
|
|
partialContext.put(key, expr.evaluate(self, context).asInstanceOf[AnyRef])
|
|
}
|
|
|
|
// Detect circular includes
|
|
val includeStack = Option(context.getVariable("__INCLUDE_STACK__"))
|
|
.map(_.asInstanceOf[java.util.List[String]].asScala.toList)
|
|
.getOrElse(List.empty)
|
|
|
|
if includeStack.contains(partialName) then
|
|
throw new RuntimeException(s"Circular partial include: $partialName (stack: ${includeStack.mkString(" -> ")})")
|
|
|
|
partialContext.put("__INCLUDE_STACK__", (includeStack :+ partialName).asJava)
|
|
|
|
// Load and render partial template
|
|
val partialPath = s"$themePath/partials/$partialName.htm"
|
|
val engine = self.getEngine
|
|
val template = engine.getTemplate(partialPath)
|
|
template.evaluate(writer, partialContext)
|
|
|
|
override def accept(visitor: NodeVisitor): Unit = visitor.visit(this)
|
|
```
|
|
|
|
**PlaceholderTag.scala:**
|
|
```scala
|
|
package theme.pebble
|
|
|
|
import io.pebbletemplates.pebble.extension.NodeVisitor
|
|
import io.pebbletemplates.pebble.node.{AbstractRenderableNode, RenderableNode}
|
|
import io.pebbletemplates.pebble.node.expression.Expression
|
|
import io.pebbletemplates.pebble.template.{EvaluationContextImpl, PebbleTemplateImpl}
|
|
import io.pebbletemplates.pebble.tokenParser.TokenParser
|
|
import io.pebbletemplates.pebble.lexer.Token
|
|
import io.pebbletemplates.pebble.parser.Parser
|
|
import java.io.Writer
|
|
|
|
/**
|
|
* {% placeholder 'name' %} tag - defines a named content slot in layout.
|
|
* Content is filled by {% put 'name' %}...{% endput %} in pages.
|
|
*/
|
|
class PlaceholderTag extends TokenParser:
|
|
override def getTag: String = "placeholder"
|
|
|
|
override def parse(token: Token, parser: Parser): RenderableNode =
|
|
val stream = parser.getStream
|
|
val lineNumber = token.getLineNumber
|
|
|
|
// Parse placeholder name
|
|
val placeholderName = parser.getExpressionParser.parseExpression()
|
|
|
|
stream.expect(classOf[Token.Type], "TAG_END")
|
|
PlaceholderNode(lineNumber, placeholderName)
|
|
|
|
class PlaceholderNode(
|
|
lineNumber: Int,
|
|
nameExpr: Expression[?]
|
|
) extends AbstractRenderableNode(lineNumber):
|
|
|
|
override def render(self: PebbleTemplateImpl, writer: Writer, context: EvaluationContextImpl): Unit =
|
|
val name = nameExpr.evaluate(self, context).toString
|
|
|
|
// Look for placeholder content in context
|
|
val placeholders = Option(context.getVariable("__PLACEHOLDERS__"))
|
|
.map(_.asInstanceOf[java.util.Map[String, String]])
|
|
.getOrElse(new java.util.HashMap())
|
|
|
|
val content = Option(placeholders.get(name)).getOrElse("")
|
|
writer.write(content)
|
|
|
|
override def accept(visitor: NodeVisitor): Unit = visitor.visit(this)
|
|
```
|
|
|
|
**PutTag.scala:**
|
|
```scala
|
|
package theme.pebble
|
|
|
|
import io.pebbletemplates.pebble.extension.NodeVisitor
|
|
import io.pebbletemplates.pebble.node.{AbstractRenderableNode, RenderableNode, BodyNode}
|
|
import io.pebbletemplates.pebble.node.expression.Expression
|
|
import io.pebbletemplates.pebble.template.{EvaluationContextImpl, PebbleTemplateImpl}
|
|
import io.pebbletemplates.pebble.tokenParser.TokenParser
|
|
import io.pebbletemplates.pebble.lexer.Token
|
|
import io.pebbletemplates.pebble.parser.Parser
|
|
import java.io.{StringWriter, Writer}
|
|
|
|
/**
|
|
* {% put 'name' %}...{% endput %} tag - fills a placeholder defined in layout.
|
|
* Content between tags is captured and stored for the named placeholder.
|
|
*/
|
|
class PutTag extends TokenParser:
|
|
override def getTag: String = "put"
|
|
|
|
override def parse(token: Token, parser: Parser): RenderableNode =
|
|
val stream = parser.getStream
|
|
val lineNumber = token.getLineNumber
|
|
|
|
// Parse placeholder name
|
|
val placeholderName = parser.getExpressionParser.parseExpression()
|
|
|
|
stream.expect(classOf[Token.Type], "TAG_END")
|
|
|
|
// Parse body until endput
|
|
val body = parser.subparse { t =>
|
|
t.test(classOf[Token.Type], "NAME", "endput")
|
|
}
|
|
|
|
// Consume endput tag
|
|
stream.next()
|
|
stream.expect(classOf[Token.Type], "TAG_END")
|
|
|
|
PutNode(lineNumber, placeholderName, body)
|
|
|
|
class PutNode(
|
|
lineNumber: Int,
|
|
nameExpr: Expression[?],
|
|
body: BodyNode
|
|
) extends AbstractRenderableNode(lineNumber):
|
|
|
|
override def render(self: PebbleTemplateImpl, writer: Writer, context: EvaluationContextImpl): Unit =
|
|
val name = nameExpr.evaluate(self, context).toString
|
|
|
|
// Render body to capture content
|
|
val bodyWriter = new StringWriter()
|
|
body.render(self, bodyWriter, context)
|
|
val content = bodyWriter.toString
|
|
|
|
// Store in placeholders map
|
|
val placeholders = Option(context.getVariable("__PLACEHOLDERS__"))
|
|
.map(_.asInstanceOf[java.util.Map[String, String]])
|
|
.getOrElse {
|
|
val map = new java.util.HashMap[String, String]()
|
|
context.put("__PLACEHOLDERS__", map)
|
|
map
|
|
}
|
|
|
|
placeholders.put(name, content)
|
|
|
|
override def accept(visitor: NodeVisitor): Unit =
|
|
visitor.visit(this)
|
|
body.accept(visitor)
|
|
```
|
|
|
|
**SummerThemeExtension.scala:**
|
|
```scala
|
|
package theme.pebble
|
|
|
|
import io.pebbletemplates.pebble.extension.AbstractExtension
|
|
import io.pebbletemplates.pebble.tokenParser.TokenParser
|
|
import java.util.{Map as JMap, List as JList}
|
|
import scala.jdk.CollectionConverters.*
|
|
|
|
/**
|
|
* Pebble extension providing SummerCMS theme tags.
|
|
* Tags:
|
|
* {% page %} - inject page content into layout
|
|
* {% partial 'name' %} - include partial template
|
|
* {% placeholder 'name' %} - define content slot
|
|
* {% put 'name' %}...{% endput %} - fill content slot
|
|
*/
|
|
class SummerThemeExtension extends AbstractExtension:
|
|
|
|
override def getTokenParsers: JList[TokenParser] =
|
|
List[TokenParser](
|
|
new PageTag(),
|
|
new PartialTag(),
|
|
new PlaceholderTag(),
|
|
new PutTag()
|
|
).asJava
|
|
```
|
|
|
|
**ThemeLoader.scala:**
|
|
```scala
|
|
package theme
|
|
|
|
import io.circe.yaml.parser
|
|
import zio.*
|
|
import java.nio.file.{Files, Path}
|
|
import scala.jdk.CollectionConverters.*
|
|
|
|
/** Service for discovering and loading themes */
|
|
trait ThemeLoader:
|
|
/** Load theme from directory */
|
|
def load(themePath: Path): IO[ThemeError, ThemeConfig]
|
|
|
|
/** Discover all themes in themes directory */
|
|
def discover(themesDir: Path): IO[ThemeError, List[ThemeConfig]]
|
|
|
|
object ThemeLoader:
|
|
/** Load a theme */
|
|
def load(themePath: Path): ZIO[ThemeLoader, ThemeError, ThemeConfig] =
|
|
ZIO.serviceWithZIO[ThemeLoader](_.load(themePath))
|
|
|
|
/** Discover all themes */
|
|
def discover(themesDir: Path): ZIO[ThemeLoader, ThemeError, List[ThemeConfig]] =
|
|
ZIO.serviceWithZIO[ThemeLoader](_.discover(themesDir))
|
|
|
|
/** Default themes directory */
|
|
val defaultThemesDir: Path = Path.of("themes")
|
|
|
|
/** Live implementation */
|
|
val live: ULayer[ThemeLoader] = ZLayer.succeed {
|
|
new ThemeLoader:
|
|
def load(themePath: Path): IO[ThemeError, ThemeConfig] =
|
|
val configPath = themePath.resolve("theme.yaml")
|
|
val themeCode = themePath.getFileName.toString
|
|
for
|
|
exists <- ZIO.attemptBlocking(Files.exists(configPath)).orDie
|
|
_ <- ZIO.fail(ThemeError.ThemeConfigMissing(themePath)).when(!exists)
|
|
content <- ZIO.attemptBlocking(Files.readString(configPath))
|
|
.mapError(e => ThemeError.ThemeConfigInvalid(themePath, e.getMessage))
|
|
config <- ZIO.fromEither(
|
|
parser.parse(content)
|
|
.flatMap(_.as[ThemeConfig])
|
|
.left.map(e => ThemeError.ThemeConfigInvalid(themePath, e.getMessage))
|
|
)
|
|
yield ThemeConfig.withPath(config, themePath, themeCode)
|
|
|
|
def discover(themesDir: Path): IO[ThemeError, List[ThemeConfig]] =
|
|
for
|
|
exists <- ZIO.attemptBlocking(Files.exists(themesDir)).orDie
|
|
themes <- if !exists then ZIO.succeed(List.empty)
|
|
else
|
|
ZIO.attemptBlocking {
|
|
Files.list(themesDir)
|
|
.filter(Files.isDirectory(_))
|
|
.iterator()
|
|
.asScala
|
|
.toList
|
|
}.orDie.flatMap { dirs =>
|
|
ZIO.foreach(dirs)(dir => load(dir).option).map(_.flatten)
|
|
}
|
|
yield themes
|
|
}
|
|
```
|
|
|
|
**ThemeService.scala:**
|
|
```scala
|
|
package theme
|
|
|
|
import io.pebbletemplates.pebble.PebbleEngine
|
|
import io.pebbletemplates.pebble.loader.FileLoader
|
|
import theme.pebble.SummerThemeExtension
|
|
import zio.*
|
|
import java.io.StringWriter
|
|
import java.nio.file.{Files, Path}
|
|
import scala.jdk.CollectionConverters.*
|
|
|
|
/** Service for rendering themes */
|
|
trait ThemeService:
|
|
/** Get currently active theme */
|
|
def getActive: UIO[Option[ThemeConfig]]
|
|
|
|
/** Set active theme by code */
|
|
def setActive(themeCode: String): IO[ThemeError, Unit]
|
|
|
|
/** Render a page with its layout */
|
|
def renderPage(pagePath: String, context: Map[String, Any]): IO[ThemeError, String]
|
|
|
|
/** Render a partial template */
|
|
def renderPartial(partialName: String, context: Map[String, Any]): IO[ThemeError, String]
|
|
|
|
/** Render a layout with page content */
|
|
def renderLayout(layoutName: String, pageContent: String, context: Map[String, Any]): IO[ThemeError, String]
|
|
|
|
/** Get page configuration from front matter */
|
|
def getPageConfig(pagePath: String): IO[ThemeError, PageConfig]
|
|
|
|
object ThemeService:
|
|
/** Get active theme */
|
|
def getActive: ZIO[ThemeService, Nothing, Option[ThemeConfig]] =
|
|
ZIO.serviceWithZIO[ThemeService](_.getActive)
|
|
|
|
/** Render a page */
|
|
def renderPage(pagePath: String, context: Map[String, Any]): ZIO[ThemeService, ThemeError, String] =
|
|
ZIO.serviceWithZIO[ThemeService](_.renderPage(pagePath, context))
|
|
|
|
/** Render a partial */
|
|
def renderPartial(partialName: String, context: Map[String, Any]): ZIO[ThemeService, ThemeError, String] =
|
|
ZIO.serviceWithZIO[ThemeService](_.renderPartial(partialName, context))
|
|
|
|
/** Live implementation */
|
|
def live(themesDir: Path): ZLayer[ThemeLoader, ThemeError, ThemeService] =
|
|
ZLayer.fromZIO {
|
|
for
|
|
loader <- ZIO.service[ThemeLoader]
|
|
themes <- loader.discover(themesDir)
|
|
activeRef <- Ref.make(themes.headOption)
|
|
enginesRef <- Ref.make(Map.empty[String, PebbleEngine])
|
|
yield new ThemeServiceLive(themesDir, themes, activeRef, enginesRef, loader)
|
|
}
|
|
|
|
/** Default layer */
|
|
val default: ZLayer[ThemeLoader, ThemeError, ThemeService] =
|
|
live(ThemeLoader.defaultThemesDir)
|
|
|
|
private class ThemeServiceLive(
|
|
themesDir: Path,
|
|
initialThemes: List[ThemeConfig],
|
|
activeRef: Ref[Option[ThemeConfig]],
|
|
enginesRef: Ref[Map[String, PebbleEngine]],
|
|
loader: ThemeLoader
|
|
) extends ThemeService:
|
|
|
|
def getActive: UIO[Option[ThemeConfig]] =
|
|
activeRef.get
|
|
|
|
def setActive(themeCode: String): IO[ThemeError, Unit] =
|
|
for
|
|
themePath <- ZIO.succeed(themesDir.resolve(themeCode))
|
|
config <- loader.load(themePath)
|
|
_ <- activeRef.set(Some(config))
|
|
yield ()
|
|
|
|
def renderPage(pagePath: String, context: Map[String, Any]): IO[ThemeError, String] =
|
|
for
|
|
theme <- activeRef.get.flatMap {
|
|
case Some(t) => ZIO.succeed(t)
|
|
case None => ZIO.fail(ThemeError.ThemeNotFound("no active theme"))
|
|
}
|
|
|
|
// Load page file
|
|
pageFile = theme.path.resolve("pages").resolve(s"$pagePath.htm")
|
|
exists <- ZIO.attemptBlocking(Files.exists(pageFile)).orDie
|
|
_ <- ZIO.fail(ThemeError.PageNotFound(theme.name, pagePath)).when(!exists)
|
|
|
|
content <- ZIO.attemptBlocking(Files.readString(pageFile))
|
|
.mapError(e => ThemeError.TemplateRenderError(pagePath, e))
|
|
|
|
// Parse front matter
|
|
(pageConfig, pageBody) <- PageConfig.parseFrontMatter(content, pageFile)
|
|
|
|
// Get Pebble engine for this theme
|
|
engine <- getOrCreateEngine(theme)
|
|
|
|
// Render page body
|
|
pageContext = buildContext(theme, pageConfig, context)
|
|
renderedPage <- renderTemplate(engine, pageBody, pageContext, s"page:$pagePath")
|
|
|
|
// Collect placeholders from page rendering
|
|
placeholders = pageContext.getOrElse("__PLACEHOLDERS__", new java.util.HashMap[String, String]())
|
|
.asInstanceOf[java.util.Map[String, String]]
|
|
|
|
// Render layout with page content
|
|
layoutContext = buildContext(theme, pageConfig, context) ++
|
|
Map("__PAGE_CONTENT__" -> renderedPage, "__PLACEHOLDERS__" -> placeholders)
|
|
layoutFile = theme.path.resolve("layouts").resolve(s"${pageConfig.layout}.htm")
|
|
layoutExists <- ZIO.attemptBlocking(Files.exists(layoutFile)).orDie
|
|
_ <- ZIO.fail(ThemeError.LayoutNotFound(theme.name, pageConfig.layout)).when(!layoutExists)
|
|
|
|
layoutContent <- ZIO.attemptBlocking(Files.readString(layoutFile))
|
|
.mapError(e => ThemeError.TemplateRenderError(pageConfig.layout, e))
|
|
|
|
renderedLayout <- renderTemplate(engine, layoutContent, layoutContext, s"layout:${pageConfig.layout}")
|
|
yield renderedLayout
|
|
|
|
def renderPartial(partialName: String, context: Map[String, Any]): IO[ThemeError, String] =
|
|
for
|
|
theme <- activeRef.get.flatMap {
|
|
case Some(t) => ZIO.succeed(t)
|
|
case None => ZIO.fail(ThemeError.ThemeNotFound("no active theme"))
|
|
}
|
|
|
|
partialFile = theme.path.resolve("partials").resolve(s"$partialName.htm")
|
|
exists <- ZIO.attemptBlocking(Files.exists(partialFile)).orDie
|
|
_ <- ZIO.fail(ThemeError.PartialNotFound(theme.name, partialName)).when(!exists)
|
|
|
|
content <- ZIO.attemptBlocking(Files.readString(partialFile))
|
|
.mapError(e => ThemeError.TemplateRenderError(partialName, e))
|
|
|
|
engine <- getOrCreateEngine(theme)
|
|
partialContext = buildContext(theme, PageConfig.default, context)
|
|
rendered <- renderTemplate(engine, content, partialContext, s"partial:$partialName")
|
|
yield rendered
|
|
|
|
def renderLayout(layoutName: String, pageContent: String, context: Map[String, Any]): IO[ThemeError, String] =
|
|
for
|
|
theme <- activeRef.get.flatMap {
|
|
case Some(t) => ZIO.succeed(t)
|
|
case None => ZIO.fail(ThemeError.ThemeNotFound("no active theme"))
|
|
}
|
|
|
|
layoutFile = theme.path.resolve("layouts").resolve(s"$layoutName.htm")
|
|
exists <- ZIO.attemptBlocking(Files.exists(layoutFile)).orDie
|
|
_ <- ZIO.fail(ThemeError.LayoutNotFound(theme.name, layoutName)).when(!exists)
|
|
|
|
content <- ZIO.attemptBlocking(Files.readString(layoutFile))
|
|
.mapError(e => ThemeError.TemplateRenderError(layoutName, e))
|
|
|
|
engine <- getOrCreateEngine(theme)
|
|
layoutContext = buildContext(theme, PageConfig.default, context) + ("__PAGE_CONTENT__" -> pageContent)
|
|
rendered <- renderTemplate(engine, content, layoutContext, s"layout:$layoutName")
|
|
yield rendered
|
|
|
|
def getPageConfig(pagePath: String): IO[ThemeError, PageConfig] =
|
|
for
|
|
theme <- activeRef.get.flatMap {
|
|
case Some(t) => ZIO.succeed(t)
|
|
case None => ZIO.fail(ThemeError.ThemeNotFound("no active theme"))
|
|
}
|
|
|
|
pageFile = theme.path.resolve("pages").resolve(s"$pagePath.htm")
|
|
exists <- ZIO.attemptBlocking(Files.exists(pageFile)).orDie
|
|
_ <- ZIO.fail(ThemeError.PageNotFound(theme.name, pagePath)).when(!exists)
|
|
|
|
content <- ZIO.attemptBlocking(Files.readString(pageFile))
|
|
.mapError(e => ThemeError.FrontMatterParseError(pageFile, e.getMessage))
|
|
|
|
(config, _) <- PageConfig.parseFrontMatter(content, pageFile)
|
|
yield config
|
|
|
|
private def getOrCreateEngine(theme: ThemeConfig): UIO[PebbleEngine] =
|
|
enginesRef.modify { engines =>
|
|
engines.get(theme.code) match
|
|
case Some(engine) => (engine, engines)
|
|
case None =>
|
|
val engine = new PebbleEngine.Builder()
|
|
.loader(new FileLoader())
|
|
.autoEscaping(true)
|
|
.strictVariables(false)
|
|
.extension(new SummerThemeExtension())
|
|
.cacheActive(true) // Enable caching in production
|
|
.build()
|
|
(engine, engines + (theme.code -> engine))
|
|
}
|
|
|
|
private def renderTemplate(
|
|
engine: PebbleEngine,
|
|
templateContent: String,
|
|
context: Map[String, Any],
|
|
templateName: String
|
|
): IO[ThemeError, String] =
|
|
ZIO.attemptBlocking {
|
|
// Use string loader for dynamic content
|
|
val stringEngine = new PebbleEngine.Builder()
|
|
.loader(new io.pebbletemplates.pebble.loader.StringLoader())
|
|
.autoEscaping(true)
|
|
.strictVariables(false)
|
|
.extension(new SummerThemeExtension())
|
|
.build()
|
|
|
|
val template = stringEngine.getTemplate(templateContent)
|
|
val writer = new StringWriter()
|
|
template.evaluate(writer, toJavaContext(context))
|
|
writer.toString
|
|
}.mapError(e => ThemeError.TemplateRenderError(templateName, e))
|
|
|
|
private def buildContext(
|
|
theme: ThemeConfig,
|
|
pageConfig: PageConfig,
|
|
extraContext: Map[String, Any]
|
|
): Map[String, Any] =
|
|
Map(
|
|
"theme" -> Map(
|
|
"name" -> theme.name,
|
|
"code" -> theme.code,
|
|
"rendering" -> theme.rendering.toString.toLowerCase
|
|
),
|
|
"page" -> Map(
|
|
"title" -> pageConfig.title.getOrElse(""),
|
|
"description" -> pageConfig.description.getOrElse(""),
|
|
"layout" -> pageConfig.layout
|
|
),
|
|
"renderingMode" -> pageConfig.effectiveRendering(theme.rendering).toString.toLowerCase,
|
|
"__THEME_PATH__" -> theme.path.toString,
|
|
"__PLACEHOLDERS__" -> new java.util.HashMap[String, String]()
|
|
) ++ extraContext
|
|
|
|
private def toJavaContext(context: Map[String, Any]): java.util.Map[String, AnyRef] =
|
|
context.view.mapValues(toJavaValue).toMap.asJava
|
|
|
|
private def toJavaValue(value: Any): AnyRef = value match
|
|
case null => null
|
|
case s: String => s
|
|
case n: Int => Integer.valueOf(n)
|
|
case n: Long => java.lang.Long.valueOf(n)
|
|
case n: Double => java.lang.Double.valueOf(n)
|
|
case b: Boolean => java.lang.Boolean.valueOf(b)
|
|
case m: Map[?, ?] => m.map { case (k, v) => k.toString -> toJavaValue(v) }.asJava
|
|
case l: List[?] => l.map(toJavaValue).asJava
|
|
case l: Seq[?] => l.map(toJavaValue).asJava
|
|
case opt: Option[?] => opt.map(toJavaValue).orNull
|
|
case other => other.asInstanceOf[AnyRef]
|
|
```
|
|
</action>
|
|
<verify>
|
|
Run `./mill summercms.compile` - all files compile.
|
|
|
|
Create a test theme structure for manual verification:
|
|
```bash
|
|
mkdir -p themes/test-theme/{layouts,pages,partials}
|
|
|
|
cat > themes/test-theme/theme.yaml << 'EOF'
|
|
name: Test Theme
|
|
description: A test theme for SummerCMS
|
|
rendering: hybrid
|
|
EOF
|
|
|
|
cat > themes/test-theme/layouts/default.htm << 'EOF'
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>{{ page.title }} | {{ theme.name }}</title>
|
|
{% placeholder 'styles' %}
|
|
</head>
|
|
<body>
|
|
{% partial 'header' %}
|
|
<main>
|
|
{% page %}
|
|
</main>
|
|
{% placeholder 'scripts' %}
|
|
</body>
|
|
</html>
|
|
EOF
|
|
|
|
cat > themes/test-theme/partials/header.htm << 'EOF'
|
|
<header>
|
|
<h1>{{ theme.name }}</h1>
|
|
</header>
|
|
EOF
|
|
|
|
cat > themes/test-theme/pages/home.htm << 'EOF'
|
|
---
|
|
layout: default
|
|
title: Home
|
|
---
|
|
<h2>Welcome</h2>
|
|
{% put 'scripts' %}
|
|
<script>console.log('home');</script>
|
|
{% endput %}
|
|
EOF
|
|
```
|
|
|
|
Test in Mill console:
|
|
```scala
|
|
import theme._
|
|
import zio._
|
|
|
|
val program = for {
|
|
_ <- ThemeLoader.load(java.nio.file.Path.of("themes/test-theme"))
|
|
.provide(ThemeLoader.live)
|
|
html <- ThemeService.renderPage("home", Map.empty)
|
|
.provide(ThemeLoader.live >>> ThemeService.default)
|
|
_ <- Console.printLine(html)
|
|
} yield ()
|
|
|
|
Unsafe.unsafe { implicit unsafe =>
|
|
Runtime.default.unsafe.run(program).getOrThrow()
|
|
}
|
|
```
|
|
</verify>
|
|
<done>Custom Pebble tags (page, partial, placeholder, put), ThemeLoader for discovery, ThemeService for rendering pages with layout composition</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
1. `./mill summercms.compile` succeeds with all new dependencies and types
|
|
2. Theme discovery finds themes in themes/ directory
|
|
3. theme.yaml parses to ThemeConfig with rendering mode
|
|
4. Page front matter parses to PageConfig with layout and title
|
|
5. {% page %} tag injects page content into layout
|
|
6. {% partial 'name' %} includes partial templates
|
|
7. {% placeholder 'name' %} and {% put 'name' %} work for content slots
|
|
8. Circular partial includes are detected and throw error
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- build.mill has pebble-scala, ZCaffeine dependencies
|
|
- ThemeConfig parses from theme.yaml with rendering mode, assets, vue config
|
|
- PageConfig parses front matter with layout, title, rendering override
|
|
- ThemeLoader discovers themes and loads configuration
|
|
- ThemeService renders pages with layout composition
|
|
- Custom Pebble tags: page, partial, placeholder, put
|
|
- Partial includes support variable passing (named args and "with")
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/04-theme-engine/04-01-SUMMARY.md`
|
|
</output>
|