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
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
Recommended Component Directory Structure
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
Reffor 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 atcomponent.yaml- Synchronous handlers: All handlers return
ZIO
Open Questions
Things that couldn't be fully resolved:
-
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
-
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
-
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
-
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
-
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)
- ZIO HTTP HTMX Module - Official module, version 3.3.0
- ZIO HTTP SSE Examples - Server-Sent Events pattern
- Pebble Templates - Twig-compatible templating, version 4.1.1
- htmx4s Library - Type-safe HTMX constants for Scala
- ScalaTags Documentation - Programmatic HTML generation
- HTMX OOB Swaps - Out-of-band swap patterns
- HTMX CSRF Patterns - Security best practices
Secondary (MEDIUM confidence)
- rockthejvm/scalatags-htmx-demo - ZIO + ScalaTags + HTMX architecture
- WinterCMS ComponentBase.php - Reference implementation (local file)
- WinterCMS Inspector documentation - Property schema format (local file)
- OWASP CSRF Prevention - Token patterns
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)