# Phase 4: Theme Engine - Research **Researched:** 2026-02-05 **Domain:** Template rendering, asset bundling, Vue hydration, static site generation **Confidence:** MEDIUM-HIGH ## Summary This phase implements the theme engine for SummerCMS, enabling layouts, partials, asset management, and Vue integration with multiple rendering modes (SPA, hybrid, static). The research covered Scala template libraries, asset bundling in the Mill ecosystem, hot reload approaches for development, Vue 3 hydration patterns, and caching strategies for static content. The recommended stack uses: **Pebble 4.1.1** with **pebble-scala 1.0.2** for Twig-style templating (aligned with Phase 3 component research), **Vite** for frontend asset bundling with manifest-based backend integration, **Mill's --watch mode** combined with classloader-based reload for development hot reload, and **ZCaffeine 0.9.10** for caching static content. Vue 3 hydration uses `createSSRApp()` with server-rendered HTML from Pebble templates. **Primary recommendation:** Use Pebble templates with Twig-style syntax for layouts/partials, integrate Vite for asset bundling with manifest.json for production, implement Vue hydration via `createSSRApp()` on pre-rendered HTML, and use ZCaffeine for static page caching with on-demand generation. ## Standard Stack The established libraries/tools for this domain: ### Core | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | Pebble | 4.1.1 | Twig-compatible templating | Jinja2/Twig syntax, template inheritance, autoescaping, i18n | | pebble-scala | 1.0.2 | Scala Pebble wrapper | Native Scala collection support, ZIO-friendly API | | Vite | 6.x | Asset bundling | Fast dev server, HMR, manifest for backend integration | | ZCaffeine | 0.9.10 | ZIO-friendly caching | In-memory cache for static pages, ZIO effects support | | circe-yaml | 0.16.1 | YAML parsing | Theme metadata (theme.yaml), page front matter | ### Supporting | Library | Version | Purpose | When to Use | |---------|---------|---------|-------------| | mill-bundler | 0.3.0 | Mill NPM/bundling plugin | If using Scala.js with Rollup/Webpack | | Vue 3 | 3.x | Frontend framework | SPA/hybrid rendering modes | | htmx | 2.x | Server interactions | Hybrid mode partial updates | | ScalaTags | 0.13.x | Programmatic HTML | Asset tag generation, error pages | ### Alternatives Considered | Instead of | Could Use | Tradeoff | |------------|-----------|----------| | Pebble | Scalate | Scalate lacks active maintenance, dropped by Scalatra | | Pebble | Twirl | Twirl uses Scala syntax, not Twig-compatible for WinterCMS migration | | Vite | esbuild direct | esbuild lacks HMR and dev server, Vite wraps it better | | Vite | mill-bundler | mill-bundler is Scala.js focused, Vite better for theme assets | | ZCaffeine | ScalaCache | ZCaffeine has native ZIO integration, ScalaCache needs mode configuration | **Installation (Mill build.mill):** ```scala def mvnDeps = Seq( // Existing deps from Phase 1/2/3... // Twig-compatible templating mvn"io.pebbletemplates:pebble:4.1.1", mvn"com.sfxcode.templating::pebble-scala:1.0.2", // ZIO-friendly caching for static pages mvn"com.stuart::zcaffeine:0.9.10" ) ``` **Vite Setup (package.json in theme):** ```json { "devDependencies": { "vite": "^6.0.0", "vue": "^3.5.0" }, "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" } } ``` ## Architecture Patterns ### Recommended Theme Directory Structure ``` themes/ ├── my-theme/ │ ├── theme.yaml # Theme metadata (name, rendering mode) │ ├── layouts/ │ │ ├── default.htm # Main layout with {% page %} │ │ └── minimal.htm # Alternative layout │ ├── pages/ │ │ ├── home.htm # Page with front matter │ │ └── blog/ │ │ └── post.htm # Nested page │ ├── partials/ │ │ ├── header.htm # Shared partial │ │ ├── footer.htm │ │ └── sidebar.htm │ ├── assets/ │ │ ├── css/ │ │ │ └── app.css │ │ ├── js/ │ │ │ └── app.js # Vue entry point │ │ └── images/ │ ├── vite.config.js # Vite configuration │ └── package.json # NPM dependencies ``` ### Pattern 1: Page with Layout and Front Matter **What:** Pages declare layout in YAML front matter and contain template content **When to use:** All theme pages **Example:** ```html {# themes/my-theme/pages/home.htm #} --- layout: default title: Welcome description: Home page rendering: hybrid --- {% partial 'hero' title='Welcome to SummerCMS' %}
{% for post in recentPosts %} {% partial 'post-card' with post %} {% endfor %}
{% put scripts %} {% endput %} ``` ### Pattern 2: Layout with Placeholder System **What:** Layouts define the page scaffold with {% page %} and named placeholders **When to use:** All layouts **Example:** ```html {# themes/my-theme/layouts/default.htm #} {{ page.title }} | {{ theme.name }} {# Core CSS from manifest #} {{ assetLink('css/app.css') }} {# Page-specific styles #} {% placeholder styles %} {% partial 'header' %}
{% page %}
{% partial 'footer' %} {# Core JS from manifest #} {{ assetScript('js/app.js') }} {# Page-specific scripts #} {% placeholder scripts %} {# Vue hydration for SPA mode #} {% if renderingMode == 'spa' %} {{ vueHydrationScript() }} {% endif %} ``` ### Pattern 3: Partial with Variable Passing **What:** Reusable template fragments accepting named parameters or context objects **When to use:** Any repeated template content **Example:** ```html {# themes/my-theme/partials/post-card.htm #} {# Can be called as: {% partial 'post-card' title='Hello' excerpt='...' %} or {% partial 'post-card' with post %} #}

{{ title }}

{{ excerpt | truncate(150) }}

Read more
``` ### Pattern 4: Theme Metadata (theme.yaml) **What:** Theme configuration declaring name, description, and default rendering mode **When to use:** Every theme root **Example:** ```yaml # themes/my-theme/theme.yaml name: My Theme description: A modern theme for SummerCMS author: Golem15 version: 1.0.0 # Default rendering mode (spa, hybrid, static) rendering: hybrid # Asset configuration assets: manifest: .vite/manifest.json publicPath: /themes/my-theme/assets/ # Optional: declare Vue components vue: entry: js/app.js hydrateOnLoad: true ``` ### Pattern 5: Vite Backend Integration **What:** Use Vite manifest for production asset references, dev server for development **When to use:** Asset loading in templates **Example:** ```scala // Source: Vite Backend Integration docs case class ViteManifest(entries: Map[String, ManifestEntry]) case class ManifestEntry( file: String, src: Option[String], isEntry: Option[Boolean], isDynamicEntry: Option[Boolean], css: List[String] = List.empty, assets: List[String] = List.empty, imports: List[String] = List.empty, dynamicImports: List[String] = List.empty ) trait AssetService: def scriptTag(entry: String): ZIO[Any, AssetError, Html] def linkTag(entry: String): ZIO[Any, AssetError, Html] def resolveAsset(path: String): ZIO[Any, AssetError, String] object AssetService: val live: ZLayer[AppConfig, Nothing, AssetService] = ZLayer.fromFunction { (config: AppConfig) => new AssetService: private val devMode = config.isDevelopment private val viteDevUrl = "http://localhost:5173" // Load manifest in production private lazy val manifest: Option[ViteManifest] = if devMode then None else loadManifest(config.themePath / ".vite" / "manifest.json") def scriptTag(entry: String): ZIO[Any, AssetError, Html] = if devMode then // Development: point to Vite dev server ZIO.succeed(Html(s""" """)) else // Production: use manifest manifest.flatMap(_.entries.get(entry)) match case Some(e) => val scripts = e.file +: e.imports.flatMap(i => manifest.flatMap(_.entries.get(i)).map(_.file)) ZIO.succeed(Html(scripts.map(f => s"""""" ).mkString("\n"))) case None => ZIO.fail(AssetError.NotFound(entry)) } ``` ### Pattern 6: Vue 3 Hydration for SPA Mode **What:** Server renders HTML, Vue hydrates on client with createSSRApp **When to use:** Pages with rendering: spa or theme default spa **Example:** ```javascript // themes/my-theme/assets/js/app.js // Source: Vue SSR Guide import { createSSRApp } from 'vue' import App from './App.vue' // Hydrate pre-rendered HTML const app = createSSRApp(App) // Mount in hydration mode (matches existing DOM) app.mount('#app') ``` ```html {# Layout adds hydration container #}
{% page %}
{# Hydration script loads after content #} {% if renderingMode == 'spa' %} {% endif %} ``` ### Pattern 7: Static Page Caching with ZCaffeine **What:** Cache rendered HTML for static pages with TTL-based invalidation **When to use:** Static rendering mode **Example:** ```scala // Source: ZCaffeine documentation import com.stuart.zcaffeine.* trait StaticPageCache: def get(pageKey: String): ZIO[Any, Nothing, Option[Html]] def put(pageKey: String, html: Html, ttl: Duration): UIO[Unit] def invalidate(pageKey: String): UIO[Unit] def invalidateAll: UIO[Unit] object StaticPageCache: val live: ZLayer[Any, Nothing, StaticPageCache] = ZLayer.scoped { for cache <- ZCaffeine.make[String, Html] .expireAfterWrite(Duration.ofHours(1)) .maximumSize(10000) .build yield new StaticPageCache: def get(pageKey: String) = cache.getIfPresent(pageKey) def put(pageKey: String, html: Html, ttl: Duration) = cache.put(pageKey, html) def invalidate(pageKey: String) = cache.invalidate(pageKey) def invalidateAll = cache.invalidateAll } ``` ### Pattern 8: Rendering Mode Selection **What:** Theme default in theme.yaml, per-page override in front matter **When to use:** Page rendering pipeline **Example:** ```scala // Source: CONTEXT.md decisions enum RenderingMode: case Spa // Server renders, Vue hydrates fully case Hybrid // Per-page choice, HTMX for interactions case Static // Pre-generate or cache on first request case class PageConfig( layout: String, title: Option[String], description: Option[String], rendering: Option[RenderingMode] // Override theme default ) trait PageRenderer: def render(page: PageConfig, context: Map[String, Any]): ZIO[ThemeEnv, RenderError, Response] object PageRenderer: def resolveRenderingMode( page: PageConfig, theme: ThemeConfig ): RenderingMode = page.rendering.getOrElse(theme.rendering) def render(page: PageConfig, context: Map[String, Any]): ZIO[ThemeEnv, RenderError, Response] = for theme <- ZIO.service[ThemeService].flatMap(_.getActive) mode = resolveRenderingMode(page, theme) response <- mode match case RenderingMode.Static => renderStatic(page, context, theme) case RenderingMode.Spa => renderSpa(page, context, theme) case RenderingMode.Hybrid => renderHybrid(page, context, theme) yield response ``` ### Anti-Patterns to Avoid - **Blocking template rendering:** Always wrap Pebble calls in `ZIO.attemptBlocking` - **Missing manifest fallback:** Handle missing manifest entries gracefully in development - **Hard-coded asset paths:** Always use AssetService for path resolution - **Caching user-specific content:** Never cache personalized pages in static mode - **Vue hydration mismatch:** Ensure server-rendered HTML matches Vue component structure - **Hot reload in production:** Disable file watching and use manifest in production ## Don't Hand-Roll Problems that look simple but have existing solutions: | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | Twig-compatible templates | Custom parser | Pebble | Complex inheritance, filters, autoescaping, i18n | | Asset hashing | Manual MD5 | Vite manifest | Content addressing, chunk splitting, import graph | | Hot module replacement | File watcher | Vite dev server | WebSocket-based HMR, CSS injection, module graph | | Template caching | Simple Map | ZCaffeine | LRU eviction, TTL, async loading, size limits | | Vue hydration | Manual DOM sync | createSSRApp | Mismatch detection, event binding, component matching | | Front matter parsing | Regex | circe-yaml | Edge cases, YAML spec compliance, error handling | **Key insight:** Theme engines involve many interacting concerns (templating, assets, caching, hydration) that each have mature solutions. Using proven tools prevents subtle bugs in cache invalidation, hydration mismatches, and asset versioning. ## Common Pitfalls ### Pitfall 1: Vite Dev Server CORS Issues **What goes wrong:** Browser blocks Vite assets when backend is on different port **Why it happens:** Vite dev server and Scala backend on different origins **How to avoid:** Configure Vite `server.cors: true` and proper origin headers **Warning signs:** Console errors about blocked cross-origin requests ### Pitfall 2: Vue Hydration Mismatch **What goes wrong:** Vue warns about hydration mismatch, interactive features broken **Why it happens:** Server-rendered HTML differs from Vue component output **How to avoid:** Ensure Pebble templates match Vue component structure exactly; use `data-allow-mismatch` for acceptable differences **Warning signs:** "[Vue warn]: Hydration completed but contains mismatches" ### Pitfall 3: Static Cache Serving Stale Content **What goes wrong:** Content updates not reflected on site **Why it happens:** Cache TTL too long, no invalidation on content change **How to avoid:** Implement cache invalidation hooks on content save; use short TTL during development **Warning signs:** Users reporting outdated content, changes not visible ### Pitfall 4: Missing Assets in Production **What goes wrong:** 404 errors for CSS/JS in production deployment **Why it happens:** Manifest not generated, asset paths incorrect **How to avoid:** Run `vite build` before deployment, verify manifest.json exists **Warning signs:** Working in dev, broken in production ### Pitfall 5: Template Inheritance Loops **What goes wrong:** Stack overflow during template rendering **Why it happens:** Layout A extends B, B extends A (or longer cycles) **How to avoid:** Enforce flat layout structure (CONTEXT.md decision), validate at theme load **Warning signs:** Application hang or crash on page request ### Pitfall 6: Development Hot Reload Not Working **What goes wrong:** Changes not reflected without full restart **Why it happens:** Pebble caches compiled templates, classloader not reloading **How to avoid:** Disable Pebble cache in dev mode, use Mill --watch with classloader reload **Warning signs:** Changes require restart to take effect ### Pitfall 7: Asset Injection Order Issues **What goes wrong:** JavaScript errors, CSS not applied **Why it happens:** Scripts loaded before dependencies, CSS after content paint **How to avoid:** Follow manifest import order, load CSS in head, scripts at body end **Warning signs:** "X is not defined" errors, flash of unstyled content ## Code Examples Verified patterns from official sources: ### Complete Theme Service Implementation ```scala // Source: Pebble docs + WinterCMS patterns package com.summercms.theme import io.pebbletemplates.pebble.PebbleEngine import com.sfxcode.templating.pebble.ScalaPebbleEngine import zio.* case class ThemeConfig( name: String, path: Path, rendering: RenderingMode, assetManifest: Path ) trait ThemeService: def getActive: UIO[ThemeConfig] def renderPage(pagePath: String, context: Map[String, Any]): IO[RenderError, Html] def renderLayout(layoutName: String, pageContent: Html, context: Map[String, Any]): IO[RenderError, Html] def renderPartial(partialName: String, context: Map[String, Any]): IO[RenderError, Html] object ThemeService: val live: ZLayer[AppConfig, Nothing, ThemeService] = ZLayer.fromFunction { (config: AppConfig) => new ThemeService: private val engine = ScalaPebbleEngine( globalContext = Map( "theme" -> config.activeTheme, "locale" -> config.defaultLocale ) ) // Disable cache in development if config.isDevelopment then engine.getEngine.getCacheManager.invalidateAll() def getActive: UIO[ThemeConfig] = ZIO.succeed(config.activeTheme) def renderPage(pagePath: String, context: Map[String, Any]): IO[RenderError, Html] = ZIO.attemptBlocking { val template = s"${config.activeTheme.path}/pages/$pagePath" Html(engine.evaluateToString(template, context)) }.mapError(e => RenderError.TemplateError(pagePath, e)) def renderLayout(layoutName: String, pageContent: Html, context: Map[String, Any]): IO[RenderError, Html] = ZIO.attemptBlocking { val template = s"${config.activeTheme.path}/layouts/$layoutName.htm" val layoutContext = context + ("pageContent" -> pageContent.value) Html(engine.evaluateToString(template, layoutContext)) }.mapError(e => RenderError.TemplateError(layoutName, e)) def renderPartial(partialName: String, context: Map[String, Any]): IO[RenderError, Html] = ZIO.attemptBlocking { val template = s"${config.activeTheme.path}/partials/$partialName.htm" Html(engine.evaluateToString(template, context)) }.mapError(e => RenderError.TemplateError(partialName, e)) } ``` ### Pebble Extension for Theme Functions ```scala // Source: Pebble extension API import io.pebbletemplates.pebble.extension.* import io.pebbletemplates.pebble.template.EvaluationContext class SummerThemeExtension( assetService: AssetService, csrfService: CsrfService ) extends AbstractExtension: override def getFunctions: java.util.Map[String, Function] = Map( "asset" -> new AssetFunction(assetService), "assetLink" -> new AssetLinkFunction(assetService), "assetScript" -> new AssetScriptFunction(assetService), "csrfToken" -> new CsrfTokenFunction(csrfService), "vueHydrationScript" -> new VueHydrationFunction(assetService) ).asJava override def getTags: java.util.Map[String, TokenParser] = Map( "partial" -> new PartialTag(), "page" -> new PageTag(), "placeholder" -> new PlaceholderTag(), "put" -> new PutTag() ).asJava class AssetFunction(assetService: AssetService) extends Function: override def getArgumentNames = List("path").asJava override def execute( args: java.util.Map[String, Object], self: PebbleTemplate, context: EvaluationContext, lineNumber: Int ): Object = val path = args.get("path").toString // Resolve via manifest in production, direct path in dev assetService.resolveAssetSync(path) ``` ### Page Rendering Pipeline ```scala // Source: WinterCMS lifecycle + ZIO patterns trait PagePipeline: def handle(request: Request): ZIO[ThemeEnv, RenderError, Response] object PagePipeline: def handle(request: Request): ZIO[ThemeEnv, RenderError, Response] = for // 1. Parse URL to find page pagePath <- PageResolver.resolve(request.path) // 2. Load page with front matter pageFile <- ThemeService.loadPage(pagePath) frontMatter = parseFrontMatter(pageFile.content) // 3. Determine rendering mode theme <- ThemeService.getActive mode = frontMatter.rendering.getOrElse(theme.rendering) // 4. Check static cache if applicable cached <- mode match case RenderingMode.Static => StaticPageCache.get(pagePath) case _ => ZIO.none response <- cached match case Some(html) => ZIO.succeed(Response.html(html)) case None => for // 5. Run page components components <- ComponentManager.initializeForPage(frontMatter.components) componentData <- ZIO.foreach(components)(_.onRun) // 6. Build context context = buildContext(request, frontMatter, componentData) // 7. Render page content pageHtml <- ThemeService.renderPage(pagePath, context) // 8. Render layout with page content layoutHtml <- ThemeService.renderLayout( frontMatter.layout, pageHtml, context + ("renderingMode" -> mode) ) // 9. Cache if static mode _ <- ZIO.when(mode == RenderingMode.Static) { StaticPageCache.put(pagePath, layoutHtml, Duration.ofHours(1)) } yield Response.html(layoutHtml) yield response ``` ### Vite Configuration for Backend Integration ```javascript // themes/my-theme/vite.config.js // Source: Vite Backend Integration docs import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], // Required for backend integration build: { manifest: true, outDir: 'assets/dist', rollupOptions: { input: { app: 'assets/js/app.js', // Additional entry points admin: 'assets/js/admin.js' } } }, // Dev server configuration server: { origin: 'http://localhost:5173', cors: true, // Allow Scala backend to proxy requests hmr: { host: 'localhost' } }, // CSS configuration css: { devSourcemap: true } }) ``` ## State of the Art | Old Approach | Current Approach | When Changed | Impact | |--------------|------------------|--------------|--------| | Webpack + Gulp | Vite | 2023+ | 10-100x faster dev builds, native ESM | | Manual asset versioning | Vite manifest | 2023+ | Automatic content hashing, import tracking | | Custom template engines | Pebble/Twirl | Established | Battle-tested templating, security features | | Full page reloads | HMR via Vite | 2020+ | Sub-second feedback, state preservation | | SSR via Node.js only | GraalJS on JVM | 2020+ | Vue SSR possible in JVM environments | | Guava caching | Caffeine | 2018+ | Better performance, W-TinyLFU eviction | | Manual hot reload | Mill --watch + classloader | 2024+ | 0.13s change detection vs 0.91s (SBT) | **Deprecated/outdated:** - Scalate: Maintenance declining, Scalatra dropping support - sbt-revolver: Not true hot reload, restarts entire JVM - Webpack for themes: Vite is significantly faster for development - Nashorn (Java): Removed in Java 15, use GraalJS instead ## Open Questions Things that couldn't be fully resolved: 1. **Vue SSR Without Node.js** - What we know: GraalJS can run Vue's renderToString, but has limitations (no ES6 modules natively) - What's unclear: Production performance of GraalJS for SSR workloads - Recommendation: Start with client-side hydration of server-rendered HTML (SPA mode); full SSR via GraalJS is optional optimization 2. **Hot Reload for Pebble Templates** - What we know: Pebble has cache that can be invalidated; Mill --watch detects changes in 0.13s - What's unclear: Best way to trigger cache invalidation on file change - Recommendation: Use Java WatchService or Mill's file watching, call `engine.getCacheManager.invalidateAll()` on change 3. **Asset Injection from Plugins** - What we know: WinterCMS has addCss()/addJs() pattern, components can inject assets - What's unclear: Exact mechanism to collect and deduplicate plugin assets - Recommendation: Build AssetRegistry service that collects from plugins/components, renders in layout 4. **Static Generation at Build Time** - What we know: Can iterate pages and render to files; ZCaffeine handles on-demand caching - What's unclear: Best approach for incremental static regeneration - Recommendation: CLI command for full generation, on-demand caching for updates 5. **Pebble Custom Tag Syntax** - What we know: Pebble supports custom tags via TokenParser; need {% partial %}, {% page %}, {% placeholder %} - What's unclear: Exact implementation complexity for WinterCMS-style syntax - Recommendation: Start with Pebble's include/extends, add custom tags incrementally ## Sources ### Primary (HIGH confidence) - [Pebble Templates](https://pebbletemplates.io/) - Version 4.1.1, Twig-compatible syntax - [pebble-scala](https://github.com/sfxcode/pebble-scala) - Version 1.0.2, Scala wrapper - [Vite Backend Integration](https://vite.dev/guide/backend-integration) - Manifest format, dev server config - [Vue 3 SSR Guide](https://vuejs.org/guide/scaling-up/ssr.html) - createSSRApp, hydration patterns - [ZCaffeine](https://index.scala-lang.org/stuartapp/zcaffeine) - Version 0.9.10, ZIO caching - [Mill Build Tool](https://mill-build.org/mill/scalalib/web-examples.html) - --watch mode, web examples - [WinterCMS Layouts](https://wintercms.com/docs/v1.2/docs/cms/layouts) - Layout patterns, placeholder system ### Secondary (MEDIUM confidence) - [mill-bundler](https://github.com/nafg/mill-bundler) - Version 0.3.0, Scala.js bundling - [Live Reloading on JVM](https://seroperson.me/2025/11/28/jvm-live-reload/) - Classloader reload patterns - [GraalJS Vue SSR](https://qiita.com/nannou/items/d5113cf138db8ff9287a) - Spring Boot + GraalJS + Vue ### Tertiary (LOW confidence) - Scalate documentation - Unmaintained, version unclear - scalajs-esbuild - Limited documentation for Mill integration ## Metadata **Confidence breakdown:** - Standard stack: HIGH - Pebble, Vite, ZCaffeine are well-documented and verified - Template patterns: HIGH - Based on WinterCMS reference and Pebble official docs - Asset bundling: HIGH - Vite manifest approach is standard, well-documented - Vue hydration: MEDIUM - Pattern is documented, but GraalJS SSR option is experimental - Hot reload: MEDIUM - Multiple approaches documented, integration specifics need validation - Caching: HIGH - ZCaffeine API well-documented with ZIO examples **Research date:** 2026-02-05 **Valid until:** 2026-03-05 (30 days - Vite/Vue ecosystem moves fast, verify versions)