# 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' %}

<section class="content">
  {% for post in recentPosts %}
    {% partial 'post-card' with post %}
  {% endfor %}
</section>

{% put scripts %}
<script type="module" src="{{ asset('js/home.js') }}"></script>
{% 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 #}
<!DOCTYPE html>
<html lang="{{ locale }}">
<head>
  <meta charset="UTF-8">
  <title>{{ page.title }} | {{ theme.name }}</title>

  {# Core CSS from manifest #}
  {{ assetLink('css/app.css') }}

  {# Page-specific styles #}
  {% placeholder styles %}
</head>
<body hx-headers='{"X-CSRF-Token": "{{ csrfToken() }}"}'>
  {% partial 'header' %}

  <main>
    {% page %}
  </main>

  {% 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 %}
</body>
</html>
```

### 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 %}
#}
<article class="post-card">
  <h2>{{ title }}</h2>
  <p>{{ excerpt | truncate(150) }}</p>
  <a href="{{ url }}" class="read-more">Read more</a>
</article>
```

### 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"""
              <script type="module" src="$viteDevUrl/@vite/client"></script>
              <script type="module" src="$viteDevUrl/$entry"></script>
            """))
          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"""<script type="module" src="${config.publicPath}$f"></script>"""
                ).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 #}
<div id="app" data-server-rendered="true">
  {% page %}
</div>

{# Hydration script loads after content #}
{% if renderingMode == 'spa' %}
<script type="module" src="{{ asset('js/app.js') }}"></script>
{% 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)
