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
779 lines
28 KiB
Markdown
779 lines
28 KiB
Markdown
---
|
|
phase: 03-component-system
|
|
plan: 02
|
|
type: execute
|
|
wave: 2
|
|
depends_on: ["03-01"]
|
|
files_modified:
|
|
- summercms/src/component/HtmxResponse.scala
|
|
- summercms/src/component/TemplateService.scala
|
|
- summercms/src/component/ComponentRoutes.scala
|
|
- summercms/src/component/CsrfService.scala
|
|
- summercms/src/component/package.scala
|
|
- summercms/src/api/Routes.scala
|
|
- summercms/src/Main.scala
|
|
autonomous: true
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Components can define HTMX handlers with 'on' prefix that become routable"
|
|
- "HTMX handlers receive form data and return HTML fragments"
|
|
- "HTMX responses can include triggers, retarget, reswap, and OOB swaps"
|
|
- "All HTMX POST requests require valid CSRF token"
|
|
- "Templates render via Pebble with Twig-compatible syntax"
|
|
artifacts:
|
|
- path: "summercms/src/component/HtmxResponse.scala"
|
|
provides: "HTMX response types with headers"
|
|
contains: "case class HtmxResponse"
|
|
- path: "summercms/src/component/TemplateService.scala"
|
|
provides: "Pebble-based template rendering"
|
|
exports: ["TemplateService"]
|
|
- path: "summercms/src/component/ComponentRoutes.scala"
|
|
provides: "Routes for HTMX component handlers"
|
|
contains: "POST /summer/component"
|
|
- path: "summercms/src/component/CsrfService.scala"
|
|
provides: "CSRF token generation and validation"
|
|
exports: ["CsrfService"]
|
|
key_links:
|
|
- from: "summercms/src/component/ComponentRoutes.scala"
|
|
to: "summercms/src/component/ComponentManager.scala"
|
|
via: "resolves component and invokes handler"
|
|
pattern: "ComponentManager\\.create"
|
|
- from: "summercms/src/component/ComponentRoutes.scala"
|
|
to: "summercms/src/component/CsrfService.scala"
|
|
via: "validates CSRF before handler"
|
|
pattern: "CsrfService\\.validate"
|
|
- from: "summercms/src/api/Routes.scala"
|
|
to: "summercms/src/component/ComponentRoutes.scala"
|
|
via: "combines component routes"
|
|
pattern: "ComponentRoutes\\.routes"
|
|
---
|
|
|
|
<objective>
|
|
Implement HTMX handler routing and response system for SummerCMS components.
|
|
|
|
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
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
|
|
@/home/jin/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Create HTMX response types and CSRF service</name>
|
|
<files>
|
|
summercms/src/component/HtmxResponse.scala
|
|
summercms/src/component/CsrfService.scala
|
|
</files>
|
|
<action>
|
|
**HtmxResponse.scala:**
|
|
```scala
|
|
package component
|
|
|
|
import 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"""<div id="$targetId" hx-swap-oob="$swapStrategy">${html.content}</div>"""
|
|
|
|
/**
|
|
* 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
|
|
```
|
|
</action>
|
|
<verify>Run `./mill summercms.compile` - HtmxResponse and CsrfService compile</verify>
|
|
<done>HtmxResponse with all HTMX headers and OOB swaps, CsrfService with secure token generation and validation</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create template service with Pebble</name>
|
|
<files>summercms/src/component/TemplateService.scala</files>
|
|
<action>
|
|
**TemplateService.scala:**
|
|
```scala
|
|
package component
|
|
|
|
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:**
|
|
```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:
|
|
|
|
```scala
|
|
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:
|
|
|
|
1. Import component package: `import component.componentLayer`
|
|
2. Add componentLayer to the provide chain for routes
|
|
|
|
The updated provide for startupLogic should include:
|
|
|
|
```scala
|
|
// 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:
|
|
```scala
|
|
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)
|
|
}
|
|
```
|
|
</action>
|
|
<verify>
|
|
Run `./mill summercms.compile` - all files compile.
|
|
|
|
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):
|
|
```bash
|
|
curl -X POST http://localhost:8080/summer/component/test/onTest
|
|
# Should return HTML error "Component not found: test"
|
|
```
|
|
</verify>
|
|
<done>ComponentRoutes handles POST /summer/component/{alias}/{handler}, component layer integrated with Main.scala, server reports registered component count</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
1. `./mill summercms.compile` succeeds
|
|
2. `./mill summercms.run` starts server with component count message
|
|
3. HtmxResponse.toResponse produces correct HTMX headers
|
|
4. TemplateService.renderString produces HTML from Twig-compatible template
|
|
5. POST /summer/component/alias/handler route exists and validates handler name
|
|
6. CSRF validation function available (full session integration in Phase 6)
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/03-component-system/03-02-SUMMARY.md`
|
|
</output>
|