Files
Jakub Zych 3dd38dabd8 docs(03): create phase plan
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
2026-02-05 13:45:15 +01:00

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
03-01
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
true
truths artifacts key_links
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
path provides contains
summercms/src/component/HtmxResponse.scala HTMX response types with headers case class HtmxResponse
path provides exports
summercms/src/component/TemplateService.scala Pebble-based template rendering
TemplateService
path provides contains
summercms/src/component/ComponentRoutes.scala Routes for HTMX component handlers POST /summer/component
path provides exports
summercms/src/component/CsrfService.scala CSRF token generation and validation
CsrfService
from to via pattern
summercms/src/component/ComponentRoutes.scala summercms/src/component/ComponentManager.scala resolves component and invokes handler ComponentManager.create
from to via pattern
summercms/src/component/ComponentRoutes.scala summercms/src/component/CsrfService.scala validates CSRF before handler CsrfService.validate
from to via pattern
summercms/src/api/Routes.scala summercms/src/component/ComponentRoutes.scala combines component routes ComponentRoutes.routes
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

<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 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"""

${html.content}
"""

/**

  • 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
Run `./mill summercms.compile` - HtmxResponse and CsrfService compile HtmxResponse with all HTMX headers and OOB swaps, CsrfService with secure token generation and validation Task 2: Create template service with Pebble summercms/src/component/TemplateService.scala **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:

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:

  1. Import component package: import component.componentLayer
  2. 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.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):

curl -X POST http://localhost:8080/summer/component/test/onTest
# Should return HTML error "Component not found: test"
ComponentRoutes handles POST /summer/component/{alias}/{handler}, component layer integrated with Main.scala, server reports registered component count 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)

<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>
After completion, create `.planning/phases/03-component-system/03-02-SUMMARY.md`