Files
Jakub Zych 6cc9465f35 docs(04): research theme engine domain
Phase 4: Theme Engine
- Standard stack: Pebble 4.1.1 + pebble-scala for Twig-compatible templates
- Asset bundling: Vite with manifest-based backend integration
- Hot reload: Mill --watch + classloader-based reload
- Vue hydration: createSSRApp with server-rendered HTML
- Caching: ZCaffeine for static page caching
- Architecture patterns documented for layouts, partials, rendering modes
2026-02-05 13:55:41 +01:00

27 KiB

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

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

{
  "devDependencies": {
    "vite": "^6.0.0",
    "vue": "^3.5.0"
  },
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  }
}

Architecture Patterns

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:

{# 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:

{# 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:

{# 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:

# 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:

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

// 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')
{# 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:

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

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

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

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

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

// 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)

Secondary (MEDIUM confidence)

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)