Phase 03: Component System - 2 plan(s) in 2 wave(s) - Wave 1: 03-01 (registration and lifecycle) - parallel - Wave 2: 03-02 (HTMX routing and response) - sequential - Ready for execution
28 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 03-component-system | 02 | execute | 2 |
|
|
true |
|
Purpose: Components need to respond to HTMX interactions (button clicks, form submissions) by returning HTML fragments. This plan creates the routing, CSRF protection, template service, and response types that enable interactive components.
Output: HtmxResponse type, TemplateService (Pebble), ComponentRoutes, CsrfService, integration with main Routes
<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/03-component-system/03-RESEARCH.md @.planning/phases/03-component-system/03-CONTEXT.md @.planning/phases/03-component-system/03-01-SUMMARY.md @summercms/src/component/SummerComponent.scala @summercms/src/component/ComponentManager.scala @summercms/src/api/Routes.scala @summercms/src/Main.scala Task 1: Create HTMX response types and CSRF service summercms/src/component/HtmxResponse.scala summercms/src/component/CsrfService.scala **HtmxResponse.scala:** ```scala package componentimport zio.http.* import zio.http.htmx.HtmxResponseHeaders import scalatags.Text.all.*
/** HTML fragment wrapper */ case class Html(content: String): override def toString: String = content
object Html: def apply(frag: scalatags.Text.Frag): Html = Html(frag.render) val empty: Html = Html("")
/** Out-of-band swap for updating multiple DOM elements / case class OobSwap( targetId: String, html: Html, swapStrategy: String = "outerHTML" // outerHTML, innerHTML, beforeend, etc. ): /* Render as HTML fragment with hx-swap-oob attribute */ def render: String = s"""
/**
-
HTMX response from a component handler.
-
Includes HTML content plus optional HTMX headers and OOB swaps. / case class HtmxResponse( html: Html, triggers: Map[String, String] = Map.empty, // HX-Trigger header events retarget: Option[String] = None, // HX-Retarget - change target element reswap: Option[String] = None, // HX-Reswap - change swap strategy reselect: Option[String] = None, // HX-Reselect - select subset of response pushUrl: Option[String] = None, // HX-Push-Url - update browser URL replaceUrl: Option[String] = None, // HX-Replace-Url - replace URL without push refresh: Boolean = false, // HX-Refresh - full page refresh redirect: Option[String] = None, // HX-Redirect - client-side redirect oobSwaps: List[OobSwap] = List.empty // Out-of-band swaps ): /* Convert to ZIO HTTP Response */ def toResponse: Response = val fullHtml = if oobSwaps.isEmpty then html.content else html.content + oobSwaps.map(_.render).mkString("\n")
var response = Response.html(fullHtml)
// Add HTMX headers using zio-http-htmx module if triggers.nonEmpty then val triggerJson = triggers.map { case (k, v) => if v.isEmpty then s""""$k"""" else s""""$k":"$v"""" }.mkString("{", ",", "}") response = response.addHeader(Header.Custom("HX-Trigger", triggerJson))
retarget.foreach { t => response = response.addHeader(Header.Custom("HX-Retarget", t)) }
reswap.foreach { s => response = response.addHeader(Header.Custom("HX-Reswap", s)) }
reselect.foreach { s => response = response.addHeader(Header.Custom("HX-Reselect", s)) }
pushUrl.foreach { url => response = response.addHeader(Header.Custom("HX-Push-Url", url)) }
replaceUrl.foreach { url => response = response.addHeader(Header.Custom("HX-Replace-Url", url)) }
if refresh then response = response.addHeader(Header.Custom("HX-Refresh", "true"))
redirect.foreach { url => response = response.addHeader(Header.Custom("HX-Redirect", url)) }
response
object HtmxResponse: /** Create simple HTML response */ def html(content: Html): HtmxResponse = HtmxResponse(html = content)
/** Create response from ScalaTags fragment */ def apply(frag: scalatags.Text.Frag): HtmxResponse = HtmxResponse(Html(frag))
/** Empty response (for handlers that only use OOB swaps) */ val empty: HtmxResponse = HtmxResponse(Html.empty)
**CsrfService.scala:**
```scala
package component
import zio.*
import zio.http.*
import java.security.SecureRandom
import java.util.Base64
/** CSRF token service for protecting HTMX requests */
trait CsrfService:
/** Generate a new CSRF token */
def generateToken: UIO[String]
/** Validate a token from request against session token */
def validate(request: Request, sessionToken: String): IO[ComponentError, Unit]
/** Extract token from request (header or form field) */
def extractToken(request: Request): UIO[Option[String]]
object CsrfService:
/** Token header name */
val headerName = "X-CSRF-Token"
/** Token form field name */
val formFieldName = "_token"
/** Generate a CSRF token */
def generateToken: ZIO[CsrfService, Nothing, String] =
ZIO.serviceWithZIO[CsrfService](_.generateToken)
/** Validate request token */
def validate(request: Request, sessionToken: String): ZIO[CsrfService, ComponentError, Unit] =
ZIO.serviceWithZIO[CsrfService](_.validate(request, sessionToken))
/** Live implementation */
val live: ULayer[CsrfService] = ZLayer.succeed {
new CsrfService:
private val random = new SecureRandom()
private val encoder = Base64.getUrlEncoder.withoutPadding()
def generateToken: UIO[String] = ZIO.succeed {
val bytes = new Array[Byte](32)
random.nextBytes(bytes)
encoder.encodeToString(bytes)
}
def extractToken(request: Request): UIO[Option[String]] = ZIO.succeed {
// Try header first, then form field
request.header(headerName).map(_.renderedValue).orElse {
// Note: For form field extraction, need to parse body
// This is a simplified version - full implementation needs body parsing
None
}
}
def validate(request: Request, sessionToken: String): IO[ComponentError, Unit] =
for
requestToken <- extractToken(request)
_ <- ZIO.fail(ComponentError.CsrfViolation("Missing CSRF token"))
.when(requestToken.isEmpty)
_ <- ZIO.fail(ComponentError.CsrfViolation("Invalid CSRF token"))
.when(!constantTimeEquals(requestToken.get, sessionToken))
yield ()
/** Constant-time string comparison to prevent timing attacks */
private def constantTimeEquals(a: String, b: String): Boolean =
if a.length != b.length then false
else
var result = 0
var i = 0
while i < a.length do
result |= a.charAt(i) ^ b.charAt(i)
i += 1
result == 0
}
/**
* Middleware that validates CSRF on unsafe methods (POST, PUT, DELETE, PATCH).
* Requires session token to be available in request context.
*
* Note: Full implementation needs session integration.
* For now, provides validation function for use in routes.
*/
def validateRequest(request: Request): ZIO[CsrfService, ComponentError, Unit] =
// For initial implementation, skip CSRF validation if no session
// Full session integration comes in Phase 6 (Backend Authentication)
ZIO.unit // TODO: Implement with session in Phase 6
import zio.* import io.pebbletemplates.pebble.PebbleEngine import io.pebbletemplates.pebble.loader.{Loader, StringLoader} import io.pebbletemplates.pebble.template.PebbleTemplate import io.pebbletemplates.pebble.extension.AbstractExtension import io.pebbletemplates.pebble.extension.Function import java.io.StringWriter import java.nio.file.{Files, Path} import java.util.{Map as JMap, List as JList} import scala.jdk.CollectionConverters.*
/** Template rendering service using Pebble (Twig-compatible) / trait TemplateService: /* Render a template file with context */ def render(templatePath: Path, context: Map[String, Any]): IO[ComponentError, Html]
/** Render a template string with context */ def renderString(template: String, context: Map[String, Any]): IO[ComponentError, Html]
object TemplateService: /** Render a template file */ def render(templatePath: Path, context: Map[String, Any]): ZIO[TemplateService, ComponentError, Html] = ZIO.serviceWithZIO[TemplateService](_.render(templatePath, context))
/** Render a template string */ def renderString(template: String, context: Map[String, Any]): ZIO[TemplateService, ComponentError, Html] = ZIO.serviceWithZIO[TemplateService](_.renderString(template, context))
/** Live implementation */ val live: ULayer[TemplateService] = ZLayer.succeed { new TemplateService: // Main engine for file-based templates (caches templates) private val fileEngine = new PebbleEngine.Builder() .autoEscaping(true) .strictVariables(false) .extension(new SummerPebbleExtension()) .build()
// Engine for string templates (no caching)
private val stringEngine = new PebbleEngine.Builder()
.loader(new StringLoader())
.autoEscaping(true)
.strictVariables(false)
.extension(new SummerPebbleExtension())
.build()
def render(templatePath: Path, context: Map[String, Any]): IO[ComponentError, Html] =
ZIO.attemptBlocking {
val template = fileEngine.getTemplate(templatePath.toString)
val writer = new StringWriter()
template.evaluate(writer, toJavaContext(context))
Html(writer.toString)
}.mapError(e => ComponentError.RenderError("unknown", templatePath.toString, e))
def renderString(template: String, context: Map[String, Any]): IO[ComponentError, Html] =
ZIO.attemptBlocking {
val compiled = stringEngine.getTemplate(template)
val writer = new StringWriter()
compiled.evaluate(writer, toJavaContext(context))
Html(writer.toString)
}.mapError(e => ComponentError.RenderError("unknown", "string", e))
/** Convert Scala map to Java map, handling nested structures */
private def toJavaContext(context: Map[String, Any]): JMap[String, AnyRef] =
context.view.mapValues(toJavaValue).toMap.asJava
private def toJavaValue(value: Any): AnyRef = value match
case null => null
case s: String => s
case n: Int => Integer.valueOf(n)
case n: Long => java.lang.Long.valueOf(n)
case n: Double => java.lang.Double.valueOf(n)
case b: Boolean => java.lang.Boolean.valueOf(b)
case m: Map[_, _] =>
m.map { case (k, v) => k.toString -> toJavaValue(v) }.asJava
case l: List[_] => l.map(toJavaValue).asJava
case l: Seq[_] => l.map(toJavaValue).asJava
case opt: Option[_] => opt.map(toJavaValue).orNull
case pv: PropertyValue => propertyValueToJava(pv)
case other => other.asInstanceOf[AnyRef]
private def propertyValueToJava(pv: PropertyValue): AnyRef = pv match
case PropertyValue.StringVal(s) => s
case PropertyValue.BoolVal(b) => java.lang.Boolean.valueOf(b)
case PropertyValue.IntVal(i) => Integer.valueOf(i)
case PropertyValue.ListVal(l) => l.map(propertyValueToJava).asJava
case PropertyValue.ObjectVal(m) => m.view.mapValues(propertyValueToJava).toMap.asJava
case PropertyValue.NullVal => null
}
/**
- Custom Pebble extension providing SummerCMS-specific functions and filters. */ class SummerPebbleExtension extends AbstractExtension: import java.util.{Map as JMap, List as JList}
override def getFunctions: JMap[String, Function] = Map[String, Function]( // {{ componentHandler('onRefresh') }} -> /summer/component/{alias}/onRefresh "componentHandler" -> new ComponentHandlerFunction(), // {{ csrfToken() }} -> generates CSRF token (placeholder for now) "csrfToken" -> new CsrfTokenFunction(), // {{ asset('img/logo.png') }} -> theme asset URL (placeholder for Phase 4) "asset" -> new AssetFunction() ).asJava
override def getFilters: JMap[String, io.pebbletemplates.pebble.extension.Filter] = Map[String, io.pebbletemplates.pebble.extension.Filter]( // {{ 'text'|_ }} -> Translation filter (placeholder for i18n) "_" -> new TranslateFilter() ).asJava
/** Generates component handler URLs */ class ComponentHandlerFunction extends Function: override def getArgumentNames: JList[String] = List("handler").asJava
override def execute( args: JMap[String, AnyRef], self: io.pebbletemplates.pebble.template.PebbleTemplate, context: io.pebbletemplates.pebble.template.EvaluationContext, lineNumber: Int ): AnyRef = val handler = args.get("handler").toString val alias = Option(context.getVariable("SELF")).map(_.toString).getOrElse("unknown") s"/summer/component/$alias/$handler"
/** Generates CSRF tokens */ class CsrfTokenFunction extends Function: override def getArgumentNames: JList[String] = List.empty.asJava
override def execute( args: JMap[String, AnyRef], self: io.pebbletemplates.pebble.template.PebbleTemplate, context: io.pebbletemplates.pebble.template.EvaluationContext, lineNumber: Int ): AnyRef = // For now return placeholder - real implementation needs session integration Option(context.getVariable("csrfToken")).map(.toString).getOrElse("csrf-placeholder")
/** Generates asset URLs (placeholder for Phase 4 Theme Engine) */ class AssetFunction extends Function: override def getArgumentNames: JList[String] = List("path").asJava
override def execute( args: JMap[String, AnyRef], self: io.pebbletemplates.pebble.template.PebbleTemplate, context: io.pebbletemplates.pebble.template.EvaluationContext, lineNumber: Int ): AnyRef = val path = args.get("path").toString // Placeholder - real implementation in Phase 4 s"/assets/$path"
/** Translation filter {{ 'text'|_ }} */ class TranslateFilter extends io.pebbletemplates.pebble.extension.Filter: override def getArgumentNames: JList[String] = List.empty.asJava
override def apply( input: AnyRef, args: JMap[String, AnyRef], self: io.pebbletemplates.pebble.template.PebbleTemplate, context: io.pebbletemplates.pebble.template.EvaluationContext, lineNumber: Int ): AnyRef = // Placeholder - returns input unchanged, real i18n in later phase input
</action>
<verify>Run `./mill summercms.compile` - TemplateService compiles with Pebble integration</verify>
<done>TemplateService.live provides Pebble-based template rendering with componentHandler, csrfToken, asset functions and translate filter</done>
</task>
<task type="auto">
<name>Task 3: Create component routes and integrate with application</name>
<files>
summercms/src/component/ComponentRoutes.scala
summercms/src/component/package.scala
summercms/src/api/Routes.scala
summercms/src/Main.scala
</files>
<action>
**ComponentRoutes.scala:**
```scala
package component
import zio.*
import zio.http.*
import scala.reflect.Selectable.reflectiveSelectable
/** Routes for component HTMX handlers */
object ComponentRoutes:
/** Component handler route: POST /summer/component/{alias}/{handler} */
val routes: Routes[ComponentManager & TemplateService & CsrfService, Response] =
Routes(
// HTMX handler endpoint
Method.POST / "summer" / "component" / string("alias") / string("handler") ->
handler { (alias: String, handlerName: String, req: Request) =>
handleComponentRequest(alias, handlerName, req)
},
// Component render endpoint (for initial page load)
Method.GET / "summer" / "component" / string("alias") / "render" ->
handler { (alias: String, req: Request) =>
renderComponent(alias, req)
}
)
private def handleComponentRequest(
alias: String,
handlerName: String,
req: Request
): ZIO[ComponentManager & TemplateService & CsrfService, Nothing, Response] =
(for
// Validate CSRF token
_ <- CsrfService.validateRequest(req)
// Validate handler name starts with "on"
_ <- ZIO.fail(ComponentError.InvalidHandler(alias, handlerName, "Handler must start with 'on'"))
.when(!handlerName.startsWith("on"))
// Create page context from request
ctx = pageContextFromRequest(req)
// Extract properties from request body
props <- extractPropertiesFromRequest(req)
// Create component instance
manager <- ZIO.service[ComponentManager]
component <- manager.create(alias, props, ctx)
// Initialize component
_ <- component.init.provideEnvironment(ZEnvironment(ctx))
// Invoke the handler using reflection
result <- invokeHandler(component, handlerName, req, ctx)
yield result.toResponse).catchAll { error =>
// Return error as HTML fragment with error styling
ZIO.succeed(errorResponse(error))
}
private def renderComponent(
alias: String,
req: Request
): ZIO[ComponentManager & TemplateService, Nothing, Response] =
(for
ctx = pageContextFromRequest(req)
props <- extractPropertiesFromRequest(req)
manager <- ZIO.service[ComponentManager]
component <- manager.create(alias, props, ctx)
_ <- component.init.provideEnvironment(ZEnvironment(ctx))
_ <- component.onRun.provideEnvironment(ZEnvironment(ctx))
// Render default template
templateService <- ZIO.service[TemplateService]
templatePath = component.templateDir.resolve("default.htm")
propsMap <- component.properties.get
html <- templateService.render(templatePath, buildTemplateContext(component, propsMap))
yield Response.html(html.content)).catchAll { error =>
ZIO.succeed(errorResponse(error))
}
private def pageContextFromRequest(req: Request): PageContext =
PageContext(
url = req.url.path.toString,
params = req.url.queryParams.map.view.mapValues(_.headOption.getOrElse("")).toMap,
isBackendEditor = req.header("X-Summer-Editor").isDefined
)
private def extractPropertiesFromRequest(req: Request): UIO[Map[String, PropertyValue]] =
// For now, return empty map - full form parsing in later enhancement
// Properties typically come from page/layout configuration, not request
ZIO.succeed(Map.empty)
/**
* Invoke a handler method on the component.
* Handler methods must:
* - Start with "on" (e.g., onRefresh, onLoadMore)
* - Return ZIO[ComponentEnv, ComponentError, HtmxResponse]
*
* Note: Uses Scala 3 reflection. For production, consider compile-time macro.
*/
private def invokeHandler(
component: SummerComponent,
handlerName: String,
req: Request,
ctx: PageContext
): ZIO[TemplateService, ComponentError, HtmxResponse] =
ZIO.attemptBlocking {
// Use reflection to find and invoke the handler method
val cls = component.getClass
val method = cls.getMethod(handlerName)
method.invoke(component)
}.flatMap {
case zio: ZIO[?, ?, ?] =>
// The handler returns a ZIO - we need to run it
zio.asInstanceOf[ZIO[ComponentEnv, ComponentError, HtmxResponse]]
.provideEnvironment(ZEnvironment(ctx))
case response: HtmxResponse =>
// Handler returned a direct response
ZIO.succeed(response)
case other =>
ZIO.fail(ComponentError.InvalidHandler(
component.alias,
handlerName,
s"Handler must return ZIO[ComponentEnv, ComponentError, HtmxResponse], got ${other.getClass}"
))
}.catchAll {
case ce: ComponentError => ZIO.fail(ce)
case e: NoSuchMethodException =>
ZIO.fail(ComponentError.InvalidHandler(component.alias, handlerName, "Handler method not found"))
case e: Throwable =>
ZIO.fail(ComponentError.InvalidHandler(component.alias, handlerName, e.getMessage))
}
private def buildTemplateContext(
component: SummerComponent,
properties: Map[String, PropertyValue]
): Map[String, Any] =
Map(
"__SELF__" -> component.alias,
"_component" -> component.details
) ++ properties.view.mapValues(pvToAny).toMap
private def pvToAny(pv: PropertyValue): Any = pv match
case PropertyValue.StringVal(s) => s
case PropertyValue.BoolVal(b) => b
case PropertyValue.IntVal(i) => i
case PropertyValue.ListVal(l) => l.map(pvToAny)
case PropertyValue.ObjectVal(m) => m.view.mapValues(pvToAny).toMap
case PropertyValue.NullVal => null
private def errorResponse(error: ComponentError): Response =
val html = s"""<div class="summer-error" role="alert">
<strong>Component Error:</strong> ${error.message}
</div>"""
Response.html(html).status(Status.InternalServerError)
package.scala:
package object component:
import zio.*
/** Combined layer for all component services */
type ComponentServices = ComponentManager & TemplateService & CsrfService
/** Create all component service layers */
val componentLayer: ZLayer[Any, Nothing, ComponentServices] =
ComponentManager.live ++ TemplateService.live ++ CsrfService.live
Update api/Routes.scala to include component routes:
Read existing Routes.scala and add component routes:
import zio.http.*
import component.{ComponentRoutes, componentLayer}
object Routes:
/** Combined routes for the application */
val routes: Routes[Any, Response] =
HealthRoutes.routes ++ LandingRoutes.routes ++
ComponentRoutes.routes.provideLayer(componentLayer)
Note: The exact integration depends on existing Routes.scala structure. The key is to combine ComponentRoutes.routes with existing routes and provide the componentLayer.
Update Main.scala to provide component services:
Read existing Main.scala and update the layer composition. The key changes:
- Import component package:
import component.componentLayer - Add componentLayer to the provide chain for routes
The updated provide for startupLogic should include:
// In the routes Server.serve line:
res <- Server.serve(Routes.routes).provide(
Server.defaultWithPort(cfg.server.port),
QuillContext.dataSourceLayer,
componentLayer
)
However, if routes are already using the layer internally (as shown above), Main.scala may not need changes.
Full Main.scala update:
import zio.*
import zio.http.*
import zio.config.typesafe.TypesafeConfigProvider
import api.Routes
import _root_.config.{AppConfig as SummerConfig}
import db.QuillContext
import plugin.{PluginManager, PluginDiscovery, pluginLayer}
import component.{ComponentManager, componentLayer}
object Main extends ZIOAppDefault {
private val banner: String =
"""
|
| | .
| `. * | .'
| `. ._|_* .' .
| . * .' `. *
| -------| |-------
| . *`.___.' * .
| .' |* `. *
| .' * | . `.
| . |
|
| S U M M E R C M S
|""".stripMargin
override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] =
Runtime.setConfigProvider(TypesafeConfigProvider.fromResourcePath())
private val startupLogic: ZIO[PluginManager & PluginDiscovery & ComponentManager, Any, Nothing] =
for {
cfg <- ZIO.config[SummerConfig](SummerConfig.config)
_ <- Console.printLine(banner)
_ <- Console.printLine(s"Starting on port ${cfg.server.port}...")
_ <- Console.printLine("")
// Initialize plugin system
_ <- Console.printLine("Loading plugins...")
_ <- PluginManager.loadPlugins(PluginDiscovery.defaultPluginsDir)
.catchAll(e => Console.printLine(s"Plugin loading warning: ${e.message}").as(()))
_ <- PluginManager.bootAll
.catchAll(e => Console.printLine(s"Plugin boot warning: ${e.message}").as(()))
plugins <- PluginManager.listPlugins
_ <- Console.printLine(s"Loaded ${plugins.count(_._2.isActive)} plugin(s)")
// Initialize component system
components <- ComponentManager.listComponents
_ <- Console.printLine(s"Registered ${components.size} component(s)")
_ <- Console.printLine("")
// Note: Migrations are NOT auto-run. Use CLI to run migrations (Phase 5).
res <- Server.serve(Routes.routes).provide(
Server.defaultWithPort(cfg.server.port),
QuillContext.dataSourceLayer
)
} yield res
override def run: ZIO[Any, Any, Any] =
startupLogic.provide(pluginLayer ++ componentLayer)
}
Run ./mill summercms.run - server starts with messages:
- "Loaded 0 plugin(s)"
- "Registered 0 component(s)"
Test component route exists (will return 404/error since no components registered):
curl -X POST http://localhost:8080/summer/component/test/onTest
# Should return HTML error "Component not found: test"
<success_criteria>
- HtmxResponse supports triggers, retarget, reswap, OOB swaps
- Pebble templates render with componentHandler(), csrfToken(), asset() functions
- Component routes validate handler names start with "on"
- CSRF service provides secure token generation and validation
- Application starts with component system initialized
- Component handler invocation works via reflection </success_criteria>