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
This commit is contained in:
822
.planning/phases/03-component-system/03-RESEARCH.md
Normal file
822
.planning/phases/03-component-system/03-RESEARCH.md
Normal file
@@ -0,0 +1,822 @@
|
|||||||
|
# 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):**
|
||||||
|
```scala
|
||||||
|
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:**
|
||||||
|
```scala
|
||||||
|
// 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:**
|
||||||
|
```yaml
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
```scala
|
||||||
|
// 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:**
|
||||||
|
```scala
|
||||||
|
// 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:**
|
||||||
|
```scala
|
||||||
|
// 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:**
|
||||||
|
```java
|
||||||
|
// 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 %}
|
||||||
|
```
|
||||||
|
|
||||||
|
```scala
|
||||||
|
// 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:**
|
||||||
|
```scala
|
||||||
|
// 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:**
|
||||||
|
```scala
|
||||||
|
// 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:**
|
||||||
|
```scala
|
||||||
|
// 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
|
||||||
|
```scala
|
||||||
|
// 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
|
||||||
|
```scala
|
||||||
|
// 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
|
||||||
|
```scala
|
||||||
|
// 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
|
||||||
|
```scala
|
||||||
|
// 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)
|
||||||
|
- [ZIO HTTP HTMX Module](https://index.scala-lang.org/zio/zio-http/artifacts/zio-http-htmx/3.3.0) - Official module, version 3.3.0
|
||||||
|
- [ZIO HTTP SSE Examples](https://ziohttp.com/examples/server-sent-events-in-endpoints/) - Server-Sent Events pattern
|
||||||
|
- [Pebble Templates](https://pebbletemplates.io/) - Twig-compatible templating, version 4.1.1
|
||||||
|
- [htmx4s Library](https://github.com/eikek/htmx4s) - Type-safe HTMX constants for Scala
|
||||||
|
- [ScalaTags Documentation](https://com-lihaoyi.github.io/scalatags/) - Programmatic HTML generation
|
||||||
|
- [HTMX OOB Swaps](https://htmx.org/attributes/hx-swap-oob/) - Out-of-band swap patterns
|
||||||
|
- [HTMX CSRF Patterns](https://htmx.org/essays/web-security-basics-with-htmx/) - Security best practices
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
- [rockthejvm/scalatags-htmx-demo](https://github.com/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](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) - 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)
|
||||||
Reference in New Issue
Block a user