Files
summercms-initial-research/.planning/phases/03-component-system/03-RESEARCH.md
Jakub Zych a68508ba73 docs(03): research component system domain
Phase 3: Component System
- Standard stack: zio-http-htmx 3.3.0, Pebble 4.1.1, htmx4s
- Architecture patterns: YAML properties, lifecycle hooks, HTMX handlers
- WinterCMS component patterns adapted to ZIO
- Pitfalls: CSRF, XSS, state isolation, SSE connections
2026-02-05 13:38:58 +01:00

32 KiB

Phase 3: Component System - Research

Researched: 2026-02-05 Domain: Reusable UI components with YAML-defined properties, lifecycle hooks, HTMX handlers, and Twig-compatible templating Confidence: HIGH

Summary

This phase implements the component system that enables reusable UI components responding to HTMX interactions. Components are defined with YAML property schemas (similar to WinterCMS Inspector configuration), registered per-plugin, and render via Twig-compatible templates. HTMX handlers (methods prefixed with on) respond to client-side triggers and return HTML fragments for swapping.

The recommended stack uses: ZIO HTTP 3.3.0+ with the official zio-http-htmx module for HTMX response headers, Pebble 4.1.1 for Twig/Jinja2-compatible templating (chosen over ScalaTags per CONTEXT.md decision for Twig-compatible templates), circe-yaml for YAML property schema parsing (reusing Phase 2 infrastructure), and ZIO for component lifecycle management. The htmx4s library provides type-safe HTMX constants and ScalaTags integration for any programmatic HTML needs.

Primary recommendation: Define components as Scala traits with YAML property schemas parsed at plugin registration time. Use Pebble template engine for Twig-compatible rendering with default.htm convention. Implement HTMX handlers as on* methods returning ZIO[ComponentEnv, ComponentError, Html]. Integrate CSRF protection via request middleware injecting tokens, with validation on all HTMX POST requests.

Standard Stack

The established libraries/tools for this domain:

Core

Library Version Purpose Why Standard
zio-http-htmx 3.3.0 HTMX response headers Official ZIO HTTP module for HTMX integration
Pebble 4.1.1 Twig-compatible templates Java templating with Jinja2/Twig syntax, i18n, autoescaping
circe-yaml 0.16.1 YAML property parsing Already in stack from Phase 2, proven for YAML parsing
htmx4s-constants latest HTMX vocabulary Type-safe HTMX attribute/header constants

Supporting

Library Version Purpose When to Use
htmx4s-scalatags latest ScalaTags HTMX attrs When generating HTML programmatically (not templates)
ScalaTags 0.13.x Programmatic HTML Component helper methods, fragment generation
zio-http 3.3.0+ HTTP server + SSE Base HTTP server, Server-Sent Events streaming

Alternatives Considered

Instead of Could Use Tradeoff
Pebble ScalaTags only ScalaTags is type-safe but not Twig-compatible; Pebble enables WinterCMS migration
Pebble Twirl Twirl has Scala syntax, not Jinja2/Twig-compatible
Pebble Jinjava Jinjava is also Jinja2-compatible; Pebble has better Twig inheritance
htmx4s Hand-roll constants htmx4s provides IDE docs from htmx markdown, type safety

Installation (Mill build.mill):

def mvnDeps = Seq(
  // Existing deps from Phase 1/2...

  // HTMX integration
  mvn"dev.zio::zio-http-htmx:3.3.0",

  // Twig-compatible templating
  mvn"io.pebbletemplates:pebble:4.1.1",

  // HTMX constants (optional, for type-safe attributes)
  mvn"com.github.eikek::htmx4s-constants:latest",

  // ScalaTags for programmatic HTML fragments
  mvn"com.lihaoyi::scalatags:0.13.1"
)

Architecture Patterns

plugins/
├── golem15/
│   └── blog/
│       ├── plugin.yaml
│       ├── Plugin.scala              # registerComponents method
│       └── components/
│           ├── Posts.scala           # Component class
│           └── posts/
│               ├── component.yaml    # Property schema
│               ├── default.htm       # Main template (required)
│               ├── pagination.htm    # Partial template
│               └── item.htm          # Item template

Pattern 1: Component Trait with Lifecycle Hooks

What: Define components as Scala traits with init, onRun, and HTMX handler methods When to use: All components must implement this trait Example:

// Source: WinterCMS ComponentBase pattern adapted to ZIO
trait SummerComponent:
  /** Component identifier (alias used in pages) */
  def alias: String

  /** Component metadata from YAML */
  def details: ComponentDetails

  /** Property schema from component.yaml */
  def propertySchema: List[PropertyDef]

  /** Current property values (set from page/layout) */
  def properties: Ref[Map[String, PropertyValue]]

  /** Access to page context for rendering */
  def pageContext: PageContext

  /** Called once when component is initialized (before HTMX handlers) */
  def init: ZIO[ComponentEnv, ComponentError, Unit] = ZIO.unit

  /** Called when component is bound to page/layout, part of page lifecycle */
  def onRun: ZIO[ComponentEnv, ComponentError, Unit] = ZIO.unit

  /** Render the default template with current context */
  def render: ZIO[ComponentEnv, ComponentError, Html]

  /** Render a specific partial template */
  def renderPartial(name: String): ZIO[ComponentEnv, ComponentError, Html]

case class ComponentDetails(
  name: String,
  description: String,
  icon: Option[String] = None
)

Pattern 2: YAML Property Schema (WinterCMS Inspector Compatible)

What: Define component properties in YAML with explicit types and validation When to use: Every component with configurable properties Example:

# plugins/golem15/blog/components/posts/component.yaml
# Source: WinterCMS Inspector configuration format
name: Blog Posts
description: Displays a list of blog posts with pagination
icon: pencil

properties:
  postsPerPage:
    type: string
    title: Posts per page
    description: Number of posts to display per page
    default: "10"
    validation:
      integer:
        message: Posts per page must be a positive integer
        min:
          value: 1
          message: Must show at least 1 post
        max:
          value: 100
          message: Maximum 100 posts per page

  categoryFilter:
    type: dropdown
    title: Category Filter
    description: Filter posts by category
    default: ""
    showExternalParam: true    # Allow {{ :category }} binding
    # Options loaded dynamically from getCategoryFilterOptions()

  sortOrder:
    type: dropdown
    title: Sort Order
    options:
      published_at desc: Published date (newest first)
      published_at asc: Published date (oldest first)
      title asc: Title (A-Z)
    default: published_at desc

  noPostsMessage:
    type: string
    title: No posts message
    default: No posts found.
    group: Messages

  throwNotFound:
    type: checkbox
    title: Throw 404 on empty
    description: Show 404 page when no posts match filter
    default: false
    group: Advanced
// Source: circe-yaml parsing + WinterCMS property types
import io.circe.generic.auto.*
import io.circe.yaml.parser

case class ComponentSchema(
  name: String,
  description: String,
  icon: Option[String],
  properties: Map[String, PropertyDef]
)

case class PropertyDef(
  `type`: PropertyType,
  title: String,
  description: Option[String] = None,
  default: Option[PropertyValue] = None,
  options: Option[Map[String, String]] = None,  // For dropdown/set
  group: Option[String] = None,
  showExternalParam: Boolean = false,
  validation: Option[ValidationRules] = None
)

enum PropertyType:
  case String, Text, Checkbox, Dropdown, Set, Dictionary
  case Object, ObjectList, StringList, Autocomplete

sealed trait PropertyValue
object PropertyValue:
  case class StringVal(value: String) extends PropertyValue
  case class BoolVal(value: Boolean) extends PropertyValue
  case class IntVal(value: Int) extends PropertyValue
  case class ListVal(values: List[PropertyValue]) extends PropertyValue
  case class ObjectVal(fields: Map[String, PropertyValue]) extends PropertyValue

Pattern 3: HTMX Handler Convention (on* Methods)

What: Methods starting with on become HTMX handlers, routed via component alias When to use: All interactive component behaviors Example:

// Source: WinterCMS AJAX handler pattern + CONTEXT.md decisions
trait BlogPostsComponent extends SummerComponent:

  // HTMX handler: POST /summer/component/blogPosts/onLoadMore
  def onLoadMore: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
    for
      page    <- getFormValue("page").map(_.toIntOption.getOrElse(1))
      posts   <- loadPosts(page)
      html    <- renderPartial("items", Map("posts" -> posts))
      hasMore <- ZIO.succeed(posts.nonEmpty)
    yield HtmxResponse(
      html = html,
      triggers = if hasMore then Map.empty else Map("postsExhausted" -> "true"),
      retarget = None  // Use original hx-target
    )

  // HTMX handler: POST /summer/component/blogPosts/onFilter
  def onFilter: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
    for
      category <- getFormValue("category")
      posts    <- loadPosts(1, category)
      html     <- renderPartial("default")
    yield HtmxResponse(html)

case class HtmxResponse(
  html: Html,
  triggers: Map[String, String] = Map.empty,      // HX-Trigger header
  retarget: Option[String] = None,                // HX-Retarget header
  reswap: Option[String] = None,                  // HX-Reswap header
  oobSwaps: List[OobSwap] = List.empty            // Out-of-band swaps
)

case class OobSwap(
  targetId: String,
  html: Html,
  swapStrategy: String = "outerHTML"
)

Pattern 4: Component Registration in Plugin

What: Plugins register components via registerComponents method returning alias -> class mapping When to use: Plugin boot phase, collected by ComponentManager Example:

// Source: WinterCMS Plugin.registerComponents pattern
class BlogPlugin extends SummerPlugin:
  override def id = PluginId("golem15", "blog")

  override def registerComponents: Map[String, ComponentFactory] =
    Map(
      "blogPost"       -> ComponentFactory[BlogPostComponent],
      "blogPosts"      -> ComponentFactory[BlogPostsComponent],
      "blogCategories" -> ComponentFactory[BlogCategoriesComponent],
      "blogRssFeed"    -> ComponentFactory[BlogRssFeedComponent]
    )

trait ComponentFactory[C <: SummerComponent]:
  def create(alias: String, properties: Map[String, PropertyValue], ctx: PageContext): ZIO[Any, Nothing, C]

// ComponentManager collects from all plugins
trait ComponentManager:
  def listComponents: UIO[Map[String, ComponentFactory[?]]]
  def resolve(aliasOrClass: String): UIO[Option[ComponentFactory[?]]]
  def make[C <: SummerComponent](
    alias: String,
    properties: Map[String, PropertyValue],
    ctx: PageContext
  ): IO[ComponentError, C]

Pattern 5: Pebble Template Integration

What: Use Pebble engine for Twig-compatible templates with component context When to use: All component template rendering Example:

// Source: Pebble Templates documentation
// Pebble template syntax (Twig-compatible)
// plugins/golem15/blog/components/posts/default.htm
{% extends "layout" %}

{% block content %}
<div class="blog-posts" id="posts-{{ __SELF__ }}">
  {% if posts is empty %}
    <p class="no-posts">{{ noPostsMessage }}</p>
  {% else %}
    {% for post in posts %}
      {% include "posts/item" %}
    {% endfor %}

    {% if hasMorePosts %}
    <button
      hx-post="{{ componentHandler('onLoadMore') }}"
      hx-target="#posts-{{ __SELF__ }}"
      hx-swap="beforeend"
      hx-vals='{"page": {{ currentPage + 1 }}}'
      class="btn btn-load-more">
      {{ 'Load More'|_ }}
    </button>
    {% endif %}
  {% endif %}
</div>
{% endblock %}
// Source: Pebble Java API wrapped for ZIO
import io.pebbletemplates.pebble.PebbleEngine
import io.pebbletemplates.pebble.loader.FileLoader

trait TemplateService:
  def render(template: String, context: Map[String, Any]): Task[Html]
  def renderString(templateContent: String, context: Map[String, Any]): Task[Html]

object TemplateService:
  val live: ZLayer[PluginManager, Nothing, TemplateService] =
    ZLayer.fromFunction { (plugins: PluginManager) =>
      new TemplateService:
        private val engine = new PebbleEngine.Builder()
          .loader(new ComponentTemplateLoader(plugins))
          .autoEscaping(true)
          .defaultLocale(Locale.ENGLISH)
          .build()

        def render(template: String, context: Map[String, Any]): Task[Html] =
          ZIO.attemptBlocking {
            val writer = new StringWriter()
            val ctx = context.asJava
            engine.getTemplate(template).evaluate(writer, ctx)
            Html(writer.toString)
          }
    }

// Custom Twig functions/filters
// {{ asset('img/logo.png') }} -> ThemeService.asset()
// {{ componentHandler('onRefresh') }} -> component URL builder
// {{ 'text'|_ }} -> Translation filter

Pattern 6: CSRF Protection for HTMX Requests

What: Inject CSRF token via middleware, validate on all HTMX POST/PUT/DELETE When to use: All HTMX handlers (per CONTEXT.md: CSRF always required) Example:

// Source: HTMX CSRF pattern + OWASP recommendations
// Middleware injects CSRF token into response headers and validates requests
val csrfMiddleware: HandlerAspect[Any, Unit] = HandlerAspect.interceptHandler { handler =>
  Handler.fromFunctionZIO[Request] { request =>
    for
      session <- SessionService.get
      token   <- session.csrfToken
      _       <- ZIO.when(request.method.isUnsafe) {
        val headerToken = request.header("X-CSRF-Token").map(_.value)
        val formToken = request.bodyAsMultipartForm.flatMap(_.get("_token"))
        val requestToken = headerToken.orElse(formToken)
        ZIO.when(requestToken != Some(token)) {
          ZIO.fail(CsrfViolation("Invalid CSRF token"))
        }
      }
      response <- handler(request)
    yield response.addHeader("X-CSRF-Token", token)
  }
}

// Template helper for forms
// <input type="hidden" name="_token" value="{{ csrfToken() }}">

// HTMX global header injection (in base layout)
// <body hx-headers='{"X-CSRF-Token": "{{ csrfToken() }}"}'>

Pattern 7: Server-Sent Events for Real-Time Updates

What: Components can push updates to clients via SSE stream When to use: Live updates (chat, notifications, progress bars) Example:

// Source: ZIO HTTP SSE documentation
// Component declares SSE capability
trait LiveNotifications extends SummerComponent:

  def onSubscribe: ZIO[ComponentEnv, ComponentError, Response] =
    for
      userId  <- getCurrentUserId
      stream  <- NotificationService.subscribeUser(userId)
      sse     <- ZIO.succeed(
        stream.map { notification =>
          ServerSentEvent(
            data = renderNotification(notification),
            event = Some("notification"),
            id = Some(notification.id.toString)
          )
        }
      )
    yield Response(
      status = Status.Ok,
      headers = Headers(
        Header.ContentType(MediaType.text.`event-stream`),
        Header.CacheControl.NoCache
      ),
      body = Body.fromStreamSSE(sse)
    )

// Template usage
// <div hx-ext="sse" sse-connect="{{ componentHandler('onSubscribe') }}">
//   <div sse-swap="notification" hx-swap="beforeend"></div>
// </div>

Pattern 8: Out-of-Band Swaps for Multiple Updates

What: Single HTMX response updates multiple DOM elements When to use: Handler needs to update content outside its target Example:

// Source: htmx hx-swap-oob documentation
def onAddToCart: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
  for
    productId <- getFormValue("product_id")
    _         <- CartService.addItem(productId)
    cart      <- CartService.getCart
    itemHtml  <- renderPartial("cart-item", Map("item" -> cart.lastItem))
    countHtml <- renderFragment(s"""<span id="cart-count" hx-swap-oob="true">${cart.itemCount}</span>""")
  yield HtmxResponse(
    html = itemHtml,
    oobSwaps = List(
      OobSwap("cart-count", countHtml),
      OobSwap("cart-total", renderCartTotal(cart))
    )
  )

// Response includes primary HTML plus OOB fragments:
// <div>... item HTML ...</div>
// <span id="cart-count" hx-swap-oob="true">5</span>
// <span id="cart-total" hx-swap-oob="true">$99.00</span>

Anti-Patterns to Avoid

  • Blocking in handlers: Always return ZIO, never block threads
  • Direct database access in components: Use services, not repositories directly
  • Mutable component state: Use Ref for any state, properties are immutable per request
  • Missing CSRF on unsafe methods: All POST/PUT/DELETE require validation
  • String concatenation for HTML: Use templates or ScalaTags, never raw strings
  • Synchronous template rendering: Wrap Pebble in ZIO.attemptBlocking
  • Hard-coded component aliases: Use __SELF__ macro in templates for uniqueness

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
HTMX response headers Manual string headers zio-http-htmx Type-safe headers, correct formatting
HTMX attribute constants String literals htmx4s-constants IDE docs, typo prevention, refactoring
HTML escaping Manual escaping Pebble autoescaping XSS vulnerabilities, edge cases
CSRF token generation Random strings Secure random + HMAC Timing attacks, entropy issues
Property validation Custom validators Use WinterCMS patterns Complex rules (regex, ranges, dependencies)
Component discovery Manual registration Reflective scan or explicit registration Miss components, ordering issues

Key insight: Component systems appear simple but involve complex concerns: property validation, template inheritance, security (XSS, CSRF), lifecycle management, and state isolation. Use proven patterns from WinterCMS adapted to ZIO.

Common Pitfalls

Pitfall 1: Template Escaping Disabled Accidentally

What goes wrong: XSS vulnerabilities when rendering user content Why it happens: Using |raw filter or disabling autoescaping for convenience How to avoid: Keep autoescaping enabled, use |raw only for known-safe HTML fragments Warning signs: HTML entities appearing in output, or HTML rendering unexpectedly

Pitfall 2: Component State Leaking Between Requests

What goes wrong: User A sees User B's data Why it happens: Storing state in class variables instead of request-scoped Ref How to avoid: All component state via Ref created per-request, never var Warning signs: Inconsistent behavior under load, data appearing randomly

Pitfall 3: HTMX Handler Not Found (404)

What goes wrong: HTMX POST returns 404, component method exists Why it happens: Handler method doesn't start with on, or component not registered How to avoid: Naming convention enforcement, handler discovery at boot time Warning signs: Console shows POST to correct URL, but 404 returned

Pitfall 4: CSRF Token Mismatch After Cache

What goes wrong: Form submissions fail with CSRF error after page was cached Why it happens: CDN/browser cached page with old CSRF token How to avoid: Use Vary: Cookie header, or refresh token via HTMX header on each response Warning signs: Works in development, fails in production with caching

Pitfall 5: SSE Connection Exhaustion

What goes wrong: Server runs out of connections, new SSE subscriptions fail Why it happens: Clients not closing SSE connections, no timeout How to avoid: Set connection timeout, use heartbeat to detect dead connections Warning signs: Gradual slowdown, connection refused errors

Pitfall 6: Template Path Traversal

What goes wrong: Attacker reads arbitrary files via template include Why it happens: User input in template path without validation How to avoid: Whitelist allowed templates, never use user input directly in paths Warning signs: Error messages revealing file paths, unexpected template content

Pitfall 7: Blocking Pebble Rendering on ZIO Runtime

What goes wrong: Thread pool exhaustion, timeout errors under load Why it happens: Pebble is synchronous, blocking ZIO fibers How to avoid: Wrap Pebble calls in ZIO.attemptBlocking, use blocking thread pool Warning signs: High latency spikes, fiber count explosion

Code Examples

Verified patterns from official sources:

Complete Component Implementation

// Source: WinterCMS Account component pattern adapted to Scala/ZIO
package golem15.blog.components

import zio.*
import io.circe.yaml.parser
import io.pebbletemplates.pebble.PebbleEngine

class BlogPostsComponent(
  val alias: String,
  val propertySchema: List[PropertyDef],
  val properties: Ref[Map[String, PropertyValue]],
  val pageContext: PageContext,
  templateService: TemplateService,
  postService: PostService
) extends SummerComponent:

  override val details = ComponentDetails(
    name = "Blog Posts",
    description = "Displays a list of blog posts",
    icon = Some("pencil")
  )

  // Page variables set in onRun, available in template
  private var posts: List[Post] = List.empty
  private var category: Option[Category] = None
  private var pageParam: String = "page"

  override def onRun: ZIO[ComponentEnv, ComponentError, Unit] =
    for
      props <- properties.get
      catSlug = props.get("categoryFilter").collect { case StringVal(s) => s }
      cat <- catSlug.fold(ZIO.none)(slug =>
        categoryService.findBySlug(slug).map(Some(_))
      )
      perPage = props.get("postsPerPage")
        .collect { case StringVal(s) => s.toIntOption }
        .flatten.getOrElse(10)
      page = pageContext.param("page").flatMap(_.toIntOption).getOrElse(1)
      result <- postService.listFrontend(
        page = page,
        perPage = perPage,
        category = cat.map(_.id),
        published = !pageContext.isBackendEditor
      )
    yield
      this.posts = result.items
      this.category = cat
      this.pageParam = paramName("pageNumber").getOrElse("page")

  override def render: ZIO[ComponentEnv, ComponentError, Html] =
    for
      props <- properties.get
      noPostsMsg = props.get("noPostsMessage")
        .collect { case StringVal(s) => s }
        .getOrElse("No posts found.")
      html <- templateService.render(
        s"$alias/default",
        Map(
          "__SELF__" -> alias,
          "posts" -> posts.asJava,
          "category" -> category.orNull,
          "noPostsMessage" -> noPostsMsg,
          "pageParam" -> pageParam
        )
      )
    yield html

  // HTMX Handler: Load more posts
  def onLoadMore: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
    for
      page <- ZIO.serviceWithZIO[Request](_.formField("page"))
        .map(_.flatMap(_.toIntOption).getOrElse(1))
      props <- properties.get
      perPage = props.get("postsPerPage")
        .collect { case StringVal(s) => s.toIntOption }
        .flatten.getOrElse(10)
      result <- postService.listFrontend(page = page, perPage = perPage)
      html <- templateService.render(s"$alias/items", Map("posts" -> result.items.asJava))
    yield HtmxResponse(
      html = html,
      triggers = if result.hasMore then Map.empty else Map("postsExhausted" -> "true")
    )

  // Dynamic options for categoryFilter dropdown
  def getCategoryFilterOptions: UIO[Map[String, String]] =
    categoryService.listAll.map { cats =>
      Map("" -> "- All Categories -") ++ cats.map(c => c.slug -> c.name).toMap
    }

HTMX Handler Router

// Source: ZIO HTTP routing + WinterCMS component handler pattern
object ComponentRoutes:

  val routes: Routes[ComponentManager & TemplateService & SessionService, Response] =
    Routes(
      // Component HTMX handler endpoint
      Method.POST / "summer" / "component" / string("alias") / string("handler") ->
        handler { (alias: String, handlerName: String, req: Request) =>
          for
            _ <- CsrfService.validate(req)
            manager <- ZIO.service[ComponentManager]
            factory <- manager.resolve(alias).someOrFail(ComponentNotFound(alias))
            ctx <- PageContext.fromRequest(req)
            props <- extractProperties(req)
            component <- factory.create(alias, props, ctx)
            result <- invokeHandler(component, handlerName)
            response <- buildHtmxResponse(result)
          yield response
        }
    )

  private def invokeHandler(
    component: SummerComponent,
    handlerName: String
  ): IO[ComponentError, HtmxResponse] =
    // Validate handler name starts with "on"
    if !handlerName.startsWith("on") then
      ZIO.fail(InvalidHandler(s"Handler must start with 'on': $handlerName"))
    else
      // Use reflection or macro to invoke handler method
      component.invokeHandler(handlerName)

YAML Property Schema Parser

// Source: circe-yaml + WinterCMS Inspector format
import io.circe.*
import io.circe.generic.auto.*
import io.circe.yaml.parser

case class ComponentYaml(
  name: String,
  description: String,
  icon: Option[String] = None,
  properties: Map[String, PropertyDefYaml] = Map.empty
)

case class PropertyDefYaml(
  `type`: String,
  title: String,
  description: Option[String] = None,
  default: Option[Json] = None,
  options: Option[Map[String, String]] = None,
  group: Option[String] = None,
  showExternalParam: Option[Boolean] = None,
  validation: Option[ValidationYaml] = None
)

def parseComponentYaml(path: Path): IO[YamlError, ComponentSchema] =
  for
    content <- ZIO.attemptBlocking(Files.readString(path))
      .mapError(e => YamlError.ReadError(path, e))
    yaml <- ZIO.fromEither(parser.parse(content))
      .mapError(e => YamlError.ParseError(path, e))
    schema <- ZIO.fromEither(yaml.as[ComponentYaml])
      .mapError(e => YamlError.DecodeError(path, e))
  yield toComponentSchema(schema)

def toComponentSchema(yaml: ComponentYaml): ComponentSchema =
  ComponentSchema(
    name = yaml.name,
    description = yaml.description,
    icon = yaml.icon,
    properties = yaml.properties.map { case (name, prop) =>
      name -> PropertyDef(
        `type` = PropertyType.valueOf(prop.`type`.capitalize),
        title = prop.title,
        description = prop.description,
        default = prop.default.flatMap(parsePropertyValue),
        options = prop.options,
        group = prop.group,
        showExternalParam = prop.showExternalParam.getOrElse(false),
        validation = prop.validation.map(parseValidation)
      )
    }
  )

Pebble Custom Functions for Components

// Source: Pebble extension API
import io.pebbletemplates.pebble.extension.*
import io.pebbletemplates.pebble.template.EvaluationContext

class SummerPebbleExtension(
  themeService: ThemeService,
  componentContext: ComponentContext
) extends AbstractExtension:

  override def getFunctions: java.util.Map[String, Function] =
    Map(
      "asset" -> new AssetFunction(themeService),
      "componentHandler" -> new ComponentHandlerFunction(componentContext),
      "csrfToken" -> new CsrfTokenFunction(componentContext)
    ).asJava

  override def getFilters: java.util.Map[String, Filter] =
    Map(
      "_" -> new TranslateFilter(componentContext)  // {{ 'text'|_ }}
    ).asJava

class ComponentHandlerFunction(ctx: ComponentContext) extends Function:
  override def getArgumentNames = List("handler").asJava

  override def execute(
    args: java.util.Map[String, Object],
    self: PebbleTemplate,
    context: EvaluationContext,
    lineNumber: Int
  ): Object =
    val handler = args.get("handler").toString
    val alias = context.getVariable("__SELF__").toString
    s"/summer/component/$alias/$handler"

State of the Art

Old Approach Current Approach When Changed Impact
AJAX with JSON responses HTMX with HTML fragments 2020+ Simpler, less JS, server-rendered
SPA with React/Vue Server-side + HTMX 2023+ Lower complexity for CMS-style apps
Custom templating Pebble (Twig-compatible) Established Migration from WinterCMS themes
Callback-based handlers ZIO effect-based handlers ZIO 2.0 Type-safe async, resource safety
Session-based CSRF Per-request tokens + SameSite 2024+ Better security, stateless option
Polling for updates Server-Sent Events 2020+ Efficient real-time, browser-native

Deprecated/outdated:

  • jQuery AJAX: Use HTMX data attributes instead
  • PHP component classes: Scala traits with ZIO
  • defineProperties() method: YAML file at component.yaml
  • Synchronous handlers: All handlers return ZIO

Open Questions

Things that couldn't be fully resolved:

  1. Template Hot-Reload in Development

    • What we know: Pebble caches compiled templates
    • What's unclear: Best pattern for dev-mode reloading without restart
    • Recommendation: Use Pebble's cache.invalidate() on file change, integrate with Mill -w
  2. Component Inheritance Depth

    • What we know: CONTEXT.md allows component inheritance
    • What's unclear: How deep inheritance should go, multiple inheritance
    • Recommendation: Single-level inheritance only (extend one base), use composition for reuse
  3. Property Type Extensibility

    • What we know: WinterCMS has fixed property types (string, dropdown, etc.)
    • What's unclear: How plugins can define custom property types
    • Recommendation: Start with WinterCMS types, add extension mechanism in v2 if needed
  4. Pebble vs ScalaTags for Fragments

    • What we know: Pebble for templates, ScalaTags for programmatic HTML
    • What's unclear: When to use which, can they interop?
    • Recommendation: Pebble for component templates, ScalaTags for helper methods returning Html
  5. HTMX Extension Loading

    • What we know: HTMX has extensions (sse, json-enc, etc.)
    • What's unclear: How to manage extension loading per-component
    • Recommendation: Load common extensions in base layout, document per-component needs

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

  • Phase 2 Research - circe-yaml patterns (reuse)
  • Phase 1 Research - ZIO service patterns (reuse)

Metadata

Confidence breakdown:

  • Standard stack: HIGH - zio-http-htmx and Pebble verified via official sources
  • Architecture patterns: HIGH - Based on WinterCMS reference + ZIO official patterns
  • HTMX integration: HIGH - Official HTMX docs and zio-http module
  • Template engine: MEDIUM - Pebble is established but interop with ZIO needs validation
  • Property system: MEDIUM - Adapted from WinterCMS, Scala-specific validation untested

Research date: 2026-02-05 Valid until: 2026-03-05 (30 days - stable patterns, Pebble/HTMX established)