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

404 Not Found

The page $path could not be found.

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

Error

$message

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