Files
Jakub Zych 44685de65e docs(04): create phase plan
Phase 04: Theme Engine
- 2 plans in 2 waves
- Wave 1: Theme loading and layout composition (04-01)
- Wave 2: Asset pipeline and Vue integration (04-02)
- Ready for execution
2026-02-05 14:06:11 +01:00

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
04-01
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
true
truths artifacts key_links
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/
path provides exports
summercms/src/theme/asset/AssetService.scala Asset resolution with dev/prod modes
AssetService
path provides contains
summercms/src/theme/asset/ViteManifest.scala Vite manifest parsing case class ViteManifest
path provides exports
summercms/src/theme/asset/AssetRegistry.scala Plugin asset injection (addCss/addJs)
AssetRegistry
path provides exports
summercms/src/theme/static/StaticPageCache.scala ZCaffeine-based page caching
StaticPageCache
path provides contains
summercms/src/theme/PagePipeline.scala Complete page rendering pipeline object PagePipeline
from to via pattern
summercms/src/theme/asset/AssetService.scala summercms/src/theme/asset/ViteManifest.scala reads manifest for production paths manifest.entries
from to via pattern
summercms/src/theme/pebble/AssetFunctions.scala summercms/src/theme/asset/AssetService.scala resolves assets in templates AssetService.scriptTag
from to via pattern
summercms/src/theme/PagePipeline.scala summercms/src/theme/static/StaticPageCache.scala caches static pages 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

<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)
  }
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:

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")
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)
    1. Resolve URL to page file
    1. Parse front matter for config
    1. Initialize page components
    1. Run component onRun lifecycle
    1. Render page content
    1. Compose with layout
    1. 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.

</html>"""

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:

  1. Import theme routes
  2. Combine with existing routes
  3. 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:

  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:

// 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.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:

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)

<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>
After completion, create `.planning/phases/04-theme-engine/04-02-SUMMARY.md`