diff --git a/.planning/phases/03-component-system/03-RESEARCH.md b/.planning/phases/03-component-system/03-RESEARCH.md new file mode 100644 index 0000000..a1011fb --- /dev/null +++ b/.planning/phases/03-component-system/03-RESEARCH.md @@ -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 %} +
+ {% if posts is empty %} +

{{ noPostsMessage }}

+ {% else %} + {% for post in posts %} + {% include "posts/item" %} + {% endfor %} + + {% if hasMorePosts %} + + {% endif %} + {% endif %} +
+{% 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 +// + +// HTMX global header injection (in base layout) +// +``` + +### 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 +//
+//
+//
+``` + +### 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"""${cart.itemCount}""") + yield HtmxResponse( + html = itemHtml, + oobSwaps = List( + OobSwap("cart-count", countHtml), + OobSwap("cart-total", renderCartTotal(cart)) + ) + ) + +// Response includes primary HTML plus OOB fragments: +//
... item HTML ...
+// 5 +// $99.00 +``` + +### 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)