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
44 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 04-theme-engine | 02 | execute | 2 |
|
|
true |
|
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
<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/04-theme-engine/04-RESEARCH.md @.planning/phases/04-theme-engine/04-CONTEXT.md @.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:
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:
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"""<script type="module" src="$viteDevUrl/@vite/client"></script>
<script type="module" src="$viteDevUrl/$entry"></script>"""
ZIO.succeed(AssetHtml(html))
else
// Production: use manifest
manifest.get(entry) match
case Some(e) =>
val scripts = manifest.getJsForEntry(entry).map { file =>
s"""<script type="module" src="$publicPath$file"></script>"""
}
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"""<link rel="stylesheet" href="$viteDevUrl/$entry">"""
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"""<link rel="stylesheet" href="$publicPath$file">"""))
case None =>
ZIO.fail(AssetError.AssetNotFound(entry))
else
val links = cssFiles.map { file =>
s"""<link rel="stylesheet" href="$publicPath$file">"""
}
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"""
<script type="module">
import { createSSRApp } from '$viteDevUrl/@vue/runtime-dom'
// App will be mounted by theme's app.js
</script>
<script type="module" src="$viteDevUrl/$entry"></script>"""))
else
ZIO.succeed(AssetHtml(s"""
<script type="module" src="${publicPath}$entry"></script>"""))
AssetRegistry.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)
}
StaticPageCache.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:
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"<!-- Asset error: ${err.message} -->")
}
).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"<!-- Asset error: ${err.message} -->")
}
).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"""<link rel="stylesheet" href="${inj.path}" $attrs>""")
else
AssetService.resolveAsset(inj.path)
.map(url => s"""<link rel="stylesheet" href="$url" $attrs>""")
.catchAll(_ => ZIO.succeed(s"""<link rel="stylesheet" href="${inj.path}" $attrs>"""))
}
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"""<script src="${inj.path}" $attrs></script>""")
else
AssetService.resolveAsset(inj.path)
.map(url => s"""<script src="$url" $attrs></script>""")
.catchAll(_ => ZIO.succeed(s"""<script src="${inj.path}" $attrs></script>"""))
}
yield tags.mkString("\n")
).getOrThrowFiberFailure()
}
Update SummerThemeExtension.scala - replace existing file to add asset functions:
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")
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:
-
- Check static cache (if applicable)
-
- Resolve URL to page file
-
- Parse front matter for config
-
- Initialize page components
-
- Run component onRun lifecycle
-
- Render page content
-
- Compose with layout
-
- 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.serviceWithZIOThemeService 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"""
<html> <head></head>404 Not Found
The page $path could not be found.
private def errorHtml(message: String): String = s"""
<html> <head></head>Error
$message
</html>""" ```ThemeRoutes.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:
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"""<script type="module" src="$viteDevUrl/@vite/client"></script>
<script type="module" src="$viteDevUrl/$entry"></script>"""))
else
manifest.get(entry) match
case Some(e) =>
val scripts = manifest.getJsForEntry(entry).map { file =>
s"""<script type="module" src="$publicPath$file"></script>"""
}
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"""<link rel="stylesheet" href="$viteDevUrl/$entry">"""))
else
val cssFiles = manifest.getCssForEntry(entry)
if cssFiles.isEmpty then
manifest.get(entry).map(_.file) match
case Some(file) =>
ZIO.succeed(AssetHtml(s"""<link rel="stylesheet" href="$publicPath$file">"""))
case None =>
ZIO.fail(AssetError.AssetNotFound(entry))
else
ZIO.succeed(AssetHtml(cssFiles.map(f => s"""<link rel="stylesheet" href="$publicPath$f">""").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"""<script type="module" src="$viteDevUrl/$entry"></script>"""))
else
ZIO.succeed(AssetHtml(s"""<script type="module" src="${publicPath}$entry"></script>"""))
}
Update api/Routes.scala:
Read existing file and add theme routes. The key is to:
- Import theme routes
- Combine with existing routes
- Theme page routes should be LAST (catch-all)
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:
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:
- Import theme package
- Add
isDevelopmentconfig check (may need to add to AppConfig) - Create themeLayer with devMode
- Combine layers: pluginLayer ++ componentLayer ++ themeLayer
- Initialize theme system and report active theme
- Pass devMode to Routes.routes()
If AppConfig doesn't have isDevelopment, add it:
// In config/AppConfig.scala
case class ServerConfig(
port: Int,
isDevelopment: Boolean = true // Add this
)
And in application.conf:
server {
port = 8080
is-development = true // Add this for dev mode
}
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:
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
<success_criteria>
- 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 </success_criteria>