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
This commit is contained in:
@@ -68,11 +68,11 @@ Plans:
|
||||
2. Component lifecycle (init, onRun) executes correctly per request
|
||||
3. Components can define HTMX handlers that respond to data-attribute triggers
|
||||
4. HTMX handlers return HTML fragments that swap into the page
|
||||
**Plans**: TBD
|
||||
**Plans**: 2 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 03-01: Component registration and lifecycle
|
||||
- [ ] 03-02: HTMX handler routing and response
|
||||
- [ ] 03-01-PLAN.md - Component registration and lifecycle (SummerComponent trait, YAML property schemas, ComponentManager)
|
||||
- [ ] 03-02-PLAN.md - HTMX handler routing and response (HtmxResponse, Pebble templates, ComponentRoutes, CSRF)
|
||||
|
||||
### Phase 4: Theme Engine
|
||||
**Goal**: Render themes with layouts, partials, and Vue integration
|
||||
@@ -208,7 +208,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10
|
||||
|-------|----------------|--------|-----------|
|
||||
| 1. Foundation | 3/3 | Complete | 2026-02-05 |
|
||||
| 2. Plugin System | 0/3 | Planned | - |
|
||||
| 3. Component System | 0/2 | Not started | - |
|
||||
| 3. Component System | 0/2 | Planned | - |
|
||||
| 4. Theme Engine | 0/2 | Not started | - |
|
||||
| 5. CLI Scaffolding | 0/2 | Not started | - |
|
||||
| 6. Backend Authentication | 0/3 | Not started | - |
|
||||
|
||||
652
.planning/phases/03-component-system/03-01-PLAN.md
Normal file
652
.planning/phases/03-component-system/03-01-PLAN.md
Normal file
@@ -0,0 +1,652 @@
|
||||
---
|
||||
phase: 03-component-system
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- build.mill
|
||||
- summercms/src/component/SummerComponent.scala
|
||||
- summercms/src/component/ComponentDetails.scala
|
||||
- summercms/src/component/ComponentError.scala
|
||||
- summercms/src/component/PropertyDef.scala
|
||||
- summercms/src/component/PropertyValue.scala
|
||||
- summercms/src/component/ComponentSchema.scala
|
||||
- summercms/src/component/ComponentFactory.scala
|
||||
- summercms/src/component/ComponentManager.scala
|
||||
- summercms/src/plugin/PluginRegistration.scala
|
||||
autonomous: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Developer can define component properties in YAML with types (string, dropdown, checkbox)"
|
||||
- "Components have lifecycle hooks: init (once) and onRun (per request)"
|
||||
- "Plugins can register components via registerComponents method"
|
||||
- "ComponentManager collects all components from loaded plugins"
|
||||
- "Property schemas parse from YAML with validation rules"
|
||||
artifacts:
|
||||
- path: "summercms/src/component/SummerComponent.scala"
|
||||
provides: "Base trait for all components with lifecycle"
|
||||
contains: "trait SummerComponent"
|
||||
- path: "summercms/src/component/ComponentManager.scala"
|
||||
provides: "Service managing all registered components"
|
||||
exports: ["ComponentManager"]
|
||||
- path: "summercms/src/component/PropertyDef.scala"
|
||||
provides: "YAML property definition types"
|
||||
contains: "case class PropertyDef"
|
||||
- path: "summercms/src/component/ComponentSchema.scala"
|
||||
provides: "YAML component schema parser"
|
||||
contains: "object ComponentSchema"
|
||||
key_links:
|
||||
- from: "summercms/src/component/ComponentManager.scala"
|
||||
to: "summercms/src/plugin/PluginManager.scala"
|
||||
via: "collects components from plugin registrations"
|
||||
pattern: "PluginManager\\.getRegistration"
|
||||
- from: "summercms/src/component/ComponentSchema.scala"
|
||||
to: "circe-yaml"
|
||||
via: "YAML parsing"
|
||||
pattern: "parser\\.parse"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the component registration and lifecycle foundation for SummerCMS.
|
||||
|
||||
Purpose: Components are reusable UI pieces that plugins provide. This plan establishes the component trait, property schema parsing, and the manager that collects components from plugins. The HTMX routing (03-02) builds on this foundation.
|
||||
|
||||
Output: SummerComponent trait, PropertyDef/PropertyValue types, ComponentSchema parser, ComponentManager service, updated PluginRegistration
|
||||
</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
|
||||
@build.mill
|
||||
@summercms/src/plugin/PluginManager.scala
|
||||
@summercms/src/plugin/PluginRegistration.scala
|
||||
@summercms/src/plugin/SummerPlugin.scala
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add component system dependencies to build.mill</name>
|
||||
<files>build.mill</files>
|
||||
<action>
|
||||
Add the following dependencies to build.mill mvnDeps after the existing Flyway/logging dependencies:
|
||||
|
||||
```scala
|
||||
// Component system - Templating
|
||||
mvn"io.pebbletemplates:pebble:4.1.1",
|
||||
|
||||
// Component system - HTMX integration
|
||||
mvn"dev.zio::zio-http-htmx:3.0.1",
|
||||
|
||||
// Component system - ScalaTags for programmatic HTML
|
||||
mvn"com.lihaoyi::scalatags:0.13.1",
|
||||
```
|
||||
|
||||
Note:
|
||||
- Use zio-http-htmx:3.0.1 to match zio-http:3.0.1 already in build.mill
|
||||
- htmx4s-constants is optional (adds type-safe HTMX attribute constants) - skip for now, can add later if needed
|
||||
- circe-yaml:0.16.1 will be added by Phase 2 plans - if not present yet, add it:
|
||||
|
||||
```scala
|
||||
// YAML parsing (for component.yaml and plugin.yaml)
|
||||
mvn"io.circe::circe-yaml:0.16.1",
|
||||
mvn"io.circe::circe-generic:0.14.10",
|
||||
mvn"io.circe::circe-parser:0.14.10",
|
||||
```
|
||||
</action>
|
||||
<verify>Run `./mill summercms.compile` - dependencies resolve successfully</verify>
|
||||
<done>build.mill contains Pebble 4.1.1, zio-http-htmx, ScalaTags dependencies</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create component property types and schema parser</name>
|
||||
<files>
|
||||
summercms/src/component/PropertyDef.scala
|
||||
summercms/src/component/PropertyValue.scala
|
||||
summercms/src/component/ComponentSchema.scala
|
||||
summercms/src/component/ComponentError.scala
|
||||
summercms/src/component/ComponentDetails.scala
|
||||
</files>
|
||||
<action>
|
||||
Create `summercms/src/component/` directory with these files:
|
||||
|
||||
**ComponentError.scala:**
|
||||
```scala
|
||||
package component
|
||||
|
||||
import java.nio.file.Path
|
||||
|
||||
/** Component system error ADT */
|
||||
enum ComponentError:
|
||||
case SchemaNotFound(path: Path)
|
||||
case SchemaParseError(path: Path, message: String)
|
||||
case ComponentNotFound(alias: String)
|
||||
case InvalidHandler(alias: String, handler: String, reason: String)
|
||||
case RenderError(alias: String, template: String, cause: Throwable)
|
||||
case PropertyValidationError(alias: String, property: String, message: String)
|
||||
case CsrfViolation(message: String)
|
||||
|
||||
def message: String = this match
|
||||
case SchemaNotFound(p) => s"Component schema not found: $p"
|
||||
case SchemaParseError(p, m) => s"Failed to parse component schema at $p: $m"
|
||||
case ComponentNotFound(a) => s"Component not found: $a"
|
||||
case InvalidHandler(a, h, r) => s"Invalid handler '$h' on component '$a': $r"
|
||||
case RenderError(a, t, c) => s"Failed to render template '$t' for component '$a': ${c.getMessage}"
|
||||
case PropertyValidationError(a, p, m) => s"Property '$p' on component '$a' failed validation: $m"
|
||||
case CsrfViolation(m) => s"CSRF violation: $m"
|
||||
```
|
||||
|
||||
**ComponentDetails.scala:**
|
||||
```scala
|
||||
package component
|
||||
|
||||
/** Component metadata from YAML schema */
|
||||
case class ComponentDetails(
|
||||
name: String,
|
||||
description: String,
|
||||
icon: Option[String] = None
|
||||
)
|
||||
```
|
||||
|
||||
**PropertyValue.scala:**
|
||||
```scala
|
||||
package component
|
||||
|
||||
import io.circe.*
|
||||
|
||||
/** Runtime property values for components */
|
||||
sealed trait PropertyValue:
|
||||
def asString: Option[String]
|
||||
def asBoolean: Option[Boolean]
|
||||
def asInt: Option[Int]
|
||||
def asList: Option[List[PropertyValue]]
|
||||
def asMap: Option[Map[String, PropertyValue]]
|
||||
|
||||
object PropertyValue:
|
||||
case class StringVal(value: String) extends PropertyValue:
|
||||
def asString = Some(value)
|
||||
def asBoolean = None
|
||||
def asInt = value.toIntOption
|
||||
def asList = None
|
||||
def asMap = None
|
||||
|
||||
case class BoolVal(value: Boolean) extends PropertyValue:
|
||||
def asString = Some(value.toString)
|
||||
def asBoolean = Some(value)
|
||||
def asInt = None
|
||||
def asList = None
|
||||
def asMap = None
|
||||
|
||||
case class IntVal(value: Int) extends PropertyValue:
|
||||
def asString = Some(value.toString)
|
||||
def asBoolean = None
|
||||
def asInt = Some(value)
|
||||
def asList = None
|
||||
def asMap = None
|
||||
|
||||
case class ListVal(values: List[PropertyValue]) extends PropertyValue:
|
||||
def asString = None
|
||||
def asBoolean = None
|
||||
def asInt = None
|
||||
def asList = Some(values)
|
||||
def asMap = None
|
||||
|
||||
case class ObjectVal(fields: Map[String, PropertyValue]) extends PropertyValue:
|
||||
def asString = None
|
||||
def asBoolean = None
|
||||
def asInt = None
|
||||
def asList = None
|
||||
def asMap = Some(fields)
|
||||
|
||||
case object NullVal extends PropertyValue:
|
||||
def asString = None
|
||||
def asBoolean = None
|
||||
def asInt = None
|
||||
def asList = None
|
||||
def asMap = None
|
||||
|
||||
/** Parse JSON value to PropertyValue */
|
||||
def fromJson(json: Json): PropertyValue =
|
||||
json.fold(
|
||||
jsonNull = NullVal,
|
||||
jsonBoolean = BoolVal(_),
|
||||
jsonNumber = n => n.toInt.map(IntVal(_)).getOrElse(StringVal(n.toString)),
|
||||
jsonString = StringVal(_),
|
||||
jsonArray = arr => ListVal(arr.map(fromJson).toList),
|
||||
jsonObject = obj => ObjectVal(obj.toMap.view.mapValues(fromJson).toMap)
|
||||
)
|
||||
|
||||
/** Implicit decoder for circe */
|
||||
given Decoder[PropertyValue] = Decoder.instance { cursor =>
|
||||
Right(fromJson(cursor.value))
|
||||
}
|
||||
```
|
||||
|
||||
**PropertyDef.scala:**
|
||||
```scala
|
||||
package component
|
||||
|
||||
import io.circe.*
|
||||
import io.circe.generic.semiauto.*
|
||||
|
||||
/** Property type enum matching WinterCMS Inspector types */
|
||||
enum PropertyType:
|
||||
case String, Text, Number, Checkbox, Dropdown, Set
|
||||
case Dictionary, Object, ObjectList, StringList
|
||||
case Autocomplete, ColorPicker, CodeEditor, FileUpload
|
||||
|
||||
object PropertyType:
|
||||
/** Parse string to PropertyType (case-insensitive) */
|
||||
def fromString(s: String): Option[PropertyType] =
|
||||
PropertyType.values.find(_.toString.equalsIgnoreCase(s))
|
||||
|
||||
given Decoder[PropertyType] = Decoder.decodeString.emap { s =>
|
||||
fromString(s).toRight(s"Unknown property type: $s")
|
||||
}
|
||||
|
||||
/** Validation rules for a property */
|
||||
case class ValidationRules(
|
||||
required: Option[Boolean] = None,
|
||||
requiredMessage: Option[String] = None,
|
||||
min: Option[Int] = None,
|
||||
minMessage: Option[String] = None,
|
||||
max: Option[Int] = None,
|
||||
maxMessage: Option[String] = None,
|
||||
pattern: Option[String] = None,
|
||||
patternMessage: Option[String] = None
|
||||
)
|
||||
|
||||
object ValidationRules:
|
||||
given Decoder[ValidationRules] = deriveDecoder[ValidationRules]
|
||||
|
||||
/** Property definition from component.yaml */
|
||||
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: Option[Boolean] = None, // Allow {{ :param }} binding
|
||||
validation: Option[ValidationRules] = None
|
||||
):
|
||||
def propertyType: PropertyType = `type`
|
||||
def isRequired: Boolean = validation.flatMap(_.required).getOrElse(false)
|
||||
|
||||
object PropertyDef:
|
||||
given Decoder[PropertyDef] = deriveDecoder[PropertyDef]
|
||||
```
|
||||
|
||||
**ComponentSchema.scala:**
|
||||
```scala
|
||||
package component
|
||||
|
||||
import io.circe.*
|
||||
import io.circe.generic.semiauto.*
|
||||
import io.circe.yaml.parser
|
||||
import zio.*
|
||||
import java.nio.file.{Files, Path}
|
||||
|
||||
/** Component schema parsed from component.yaml */
|
||||
case class ComponentSchema(
|
||||
name: String,
|
||||
description: String,
|
||||
icon: Option[String] = None,
|
||||
properties: Map[String, PropertyDef] = Map.empty
|
||||
):
|
||||
def toDetails: ComponentDetails = ComponentDetails(name, description, icon)
|
||||
|
||||
object ComponentSchema:
|
||||
given Decoder[ComponentSchema] = deriveDecoder[ComponentSchema]
|
||||
|
||||
/** Parse YAML string into ComponentSchema */
|
||||
def parse(yaml: String): Either[String, ComponentSchema] =
|
||||
parser.parse(yaml)
|
||||
.flatMap(_.as[ComponentSchema])
|
||||
.left.map(_.getMessage)
|
||||
|
||||
/** Parse YAML file at path */
|
||||
def parseFile(path: Path): IO[ComponentError, ComponentSchema] =
|
||||
for
|
||||
exists <- ZIO.attemptBlocking(Files.exists(path))
|
||||
.orDie
|
||||
_ <- ZIO.fail(ComponentError.SchemaNotFound(path)).when(!exists)
|
||||
content <- ZIO.attemptBlocking(Files.readString(path))
|
||||
.mapError(e => ComponentError.SchemaParseError(path, e.getMessage))
|
||||
schema <- ZIO.fromEither(parse(content))
|
||||
.mapError(msg => ComponentError.SchemaParseError(path, msg))
|
||||
yield schema
|
||||
```
|
||||
</action>
|
||||
<verify>Run `./mill summercms.compile` - all component types compile without errors</verify>
|
||||
<done>PropertyValue ADT, PropertyDef with validation, PropertyType enum, ComponentSchema parser, ComponentError ADT</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Create SummerComponent trait and ComponentManager service</name>
|
||||
<files>
|
||||
summercms/src/component/SummerComponent.scala
|
||||
summercms/src/component/ComponentFactory.scala
|
||||
summercms/src/component/ComponentManager.scala
|
||||
summercms/src/plugin/PluginRegistration.scala
|
||||
</files>
|
||||
<action>
|
||||
**SummerComponent.scala:**
|
||||
```scala
|
||||
package component
|
||||
|
||||
import zio.*
|
||||
import java.nio.file.Path
|
||||
|
||||
/** Page context available during component rendering */
|
||||
case class PageContext(
|
||||
url: String,
|
||||
params: Map[String, String] = Map.empty,
|
||||
isBackendEditor: Boolean = false
|
||||
):
|
||||
def param(name: String): Option[String] = params.get(name)
|
||||
|
||||
/** Base trait for all SummerCMS components */
|
||||
trait SummerComponent:
|
||||
/** Component alias (used in page templates) */
|
||||
def alias: String
|
||||
|
||||
/** Component metadata from YAML schema */
|
||||
def details: ComponentDetails
|
||||
|
||||
/** Property schema from component.yaml */
|
||||
def propertySchema: Map[String, PropertyDef]
|
||||
|
||||
/** Current property values (set from page/layout configuration) */
|
||||
def properties: Ref[Map[String, PropertyValue]]
|
||||
|
||||
/** Page context for this request */
|
||||
def pageContext: PageContext
|
||||
|
||||
/** Path to component's template directory */
|
||||
def templateDir: Path
|
||||
|
||||
/**
|
||||
* Called once when component is initialized.
|
||||
* Use for one-time setup before any handlers or rendering.
|
||||
*/
|
||||
def init: ZIO[ComponentEnv, ComponentError, Unit] = ZIO.unit
|
||||
|
||||
/**
|
||||
* Called when component is bound to page/layout, part of page lifecycle.
|
||||
* Use for loading data needed for rendering.
|
||||
*/
|
||||
def onRun: ZIO[ComponentEnv, ComponentError, Unit] = ZIO.unit
|
||||
|
||||
/**
|
||||
* Get a property value by name, with fallback to default from schema.
|
||||
*/
|
||||
def property(name: String): UIO[Option[PropertyValue]] =
|
||||
properties.get.map { props =>
|
||||
props.get(name).orElse(propertySchema.get(name).flatMap(_.default))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a property as string.
|
||||
*/
|
||||
def propertyString(name: String): UIO[Option[String]] =
|
||||
property(name).map(_.flatMap(_.asString))
|
||||
|
||||
/**
|
||||
* Get a property as int.
|
||||
*/
|
||||
def propertyInt(name: String): UIO[Option[Int]] =
|
||||
property(name).map(_.flatMap(_.asInt))
|
||||
|
||||
/**
|
||||
* Get a property as boolean.
|
||||
*/
|
||||
def propertyBool(name: String): UIO[Option[Boolean]] =
|
||||
property(name).map(_.flatMap(_.asBoolean))
|
||||
|
||||
/** Environment available to components */
|
||||
type ComponentEnv = PageContext
|
||||
```
|
||||
|
||||
**ComponentFactory.scala:**
|
||||
```scala
|
||||
package component
|
||||
|
||||
import zio.*
|
||||
import java.nio.file.Path
|
||||
|
||||
/** Factory for creating component instances */
|
||||
trait ComponentFactory:
|
||||
/** Component class name (fully qualified) */
|
||||
def className: String
|
||||
|
||||
/** Schema parsed from component.yaml */
|
||||
def schema: ComponentSchema
|
||||
|
||||
/** Path to component's directory (contains templates) */
|
||||
def directory: Path
|
||||
|
||||
/** Create a component instance with given alias, properties, and context */
|
||||
def create(
|
||||
alias: String,
|
||||
initialProperties: Map[String, PropertyValue],
|
||||
ctx: PageContext
|
||||
): UIO[SummerComponent]
|
||||
|
||||
object ComponentFactory:
|
||||
/**
|
||||
* Create a factory from a component class and schema.
|
||||
* The factory will create instances with the given properties.
|
||||
*/
|
||||
def apply[C <: SummerComponent](
|
||||
cls: Class[C],
|
||||
schema: ComponentSchema,
|
||||
directory: Path,
|
||||
constructor: (String, Map[String, PropertyDef], Ref[Map[String, PropertyValue]], PageContext, Path) => C
|
||||
): ComponentFactory = new ComponentFactory:
|
||||
val className = cls.getName
|
||||
val schema_ = schema
|
||||
def schema = schema_
|
||||
val directory_ = directory
|
||||
def directory = directory_
|
||||
|
||||
def create(
|
||||
alias: String,
|
||||
initialProperties: Map[String, PropertyValue],
|
||||
ctx: PageContext
|
||||
): UIO[SummerComponent] =
|
||||
Ref.make(initialProperties).map { propsRef =>
|
||||
constructor(alias, schema.properties, propsRef, ctx, directory)
|
||||
}
|
||||
```
|
||||
|
||||
**ComponentManager.scala:**
|
||||
```scala
|
||||
package component
|
||||
|
||||
import zio.*
|
||||
import plugin.{PluginId, PluginManager}
|
||||
|
||||
/** Registration info for a component */
|
||||
case class ComponentRegistration(
|
||||
pluginId: PluginId,
|
||||
alias: String,
|
||||
factory: ComponentFactory
|
||||
)
|
||||
|
||||
/** Service for managing all registered components */
|
||||
trait ComponentManager:
|
||||
/** List all registered components */
|
||||
def listComponents: UIO[List[ComponentRegistration]]
|
||||
|
||||
/** Find a component by alias */
|
||||
def resolve(alias: String): UIO[Option[ComponentRegistration]]
|
||||
|
||||
/** Find a component by alias or class name */
|
||||
def resolveByAliasOrClass(aliasOrClass: String): UIO[Option[ComponentRegistration]]
|
||||
|
||||
/** Create a component instance */
|
||||
def create(
|
||||
alias: String,
|
||||
properties: Map[String, PropertyValue],
|
||||
ctx: PageContext
|
||||
): IO[ComponentError, SummerComponent]
|
||||
|
||||
/** Register a component from a plugin */
|
||||
def register(pluginId: PluginId, alias: String, factory: ComponentFactory): UIO[Unit]
|
||||
|
||||
object ComponentManager:
|
||||
/** List all components */
|
||||
def listComponents: ZIO[ComponentManager, Nothing, List[ComponentRegistration]] =
|
||||
ZIO.serviceWithZIO[ComponentManager](_.listComponents)
|
||||
|
||||
/** Resolve component by alias */
|
||||
def resolve(alias: String): ZIO[ComponentManager, Nothing, Option[ComponentRegistration]] =
|
||||
ZIO.serviceWithZIO[ComponentManager](_.resolve(alias))
|
||||
|
||||
/** Create component instance */
|
||||
def create(
|
||||
alias: String,
|
||||
properties: Map[String, PropertyValue],
|
||||
ctx: PageContext
|
||||
): ZIO[ComponentManager, ComponentError, SummerComponent] =
|
||||
ZIO.serviceWithZIO[ComponentManager](_.create(alias, properties, ctx))
|
||||
|
||||
/** Live implementation */
|
||||
val live: ULayer[ComponentManager] = ZLayer.fromZIO {
|
||||
Ref.make(Map.empty[String, ComponentRegistration]).map { registry =>
|
||||
new ComponentManager:
|
||||
def listComponents: UIO[List[ComponentRegistration]] =
|
||||
registry.get.map(_.values.toList)
|
||||
|
||||
def resolve(alias: String): UIO[Option[ComponentRegistration]] =
|
||||
registry.get.map(_.get(alias))
|
||||
|
||||
def resolveByAliasOrClass(aliasOrClass: String): UIO[Option[ComponentRegistration]] =
|
||||
registry.get.map { regs =>
|
||||
regs.get(aliasOrClass).orElse(
|
||||
regs.values.find(_.factory.className == aliasOrClass)
|
||||
)
|
||||
}
|
||||
|
||||
def create(
|
||||
alias: String,
|
||||
properties: Map[String, PropertyValue],
|
||||
ctx: PageContext
|
||||
): IO[ComponentError, SummerComponent] =
|
||||
resolve(alias).flatMap {
|
||||
case Some(reg) => reg.factory.create(alias, properties, ctx)
|
||||
case None => ZIO.fail(ComponentError.ComponentNotFound(alias))
|
||||
}
|
||||
|
||||
def register(pluginId: PluginId, alias: String, factory: ComponentFactory): UIO[Unit] =
|
||||
registry.update(_ + (alias -> ComponentRegistration(pluginId, alias, factory)))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Update PluginRegistration.scala** - read existing file and replace ComponentDef with proper import:
|
||||
|
||||
Read the existing file from Phase 2 and update the ComponentDef to use the component package:
|
||||
|
||||
```scala
|
||||
package plugin
|
||||
|
||||
import component.ComponentFactory
|
||||
|
||||
/** Data returned from plugin's register() method - declarative, no effects */
|
||||
case class PluginRegistration(
|
||||
// Components provided by this plugin (alias -> factory)
|
||||
components: Map[String, ComponentFactory] = Map.empty,
|
||||
// Permissions defined by this plugin (Phase 6)
|
||||
permissions: List[PermissionDef] = List.empty,
|
||||
// Navigation items for admin backend (Phase 8)
|
||||
navigation: List[NavigationDef] = List.empty,
|
||||
// Settings pages (Phase 8)
|
||||
settings: List[SettingDef] = List.empty,
|
||||
// Event subscriptions (Phase 2 extension API)
|
||||
events: List[EventSubscription] = List.empty,
|
||||
// Extensions to other plugins (Phase 2 extension API)
|
||||
extensions: List[ExtensionDef] = List.empty,
|
||||
// Form field definitions (for YAML-driven forms)
|
||||
fields: List[FieldDef] = List.empty
|
||||
)
|
||||
|
||||
object PluginRegistration:
|
||||
val empty: PluginRegistration = PluginRegistration()
|
||||
|
||||
// Placeholder types - will be fleshed out in later phases
|
||||
case class PermissionDef(code: String, label: String)
|
||||
case class NavigationDef(id: String, label: String, url: String, icon: String = "", order: Int = 0)
|
||||
case class SettingDef(key: String, label: String, icon: String = "")
|
||||
case class EventSubscription(eventType: String, handler: String)
|
||||
case class ExtensionDef(target: String, extensionClass: String)
|
||||
case class FieldDef(name: String, fieldType: String)
|
||||
```
|
||||
|
||||
Note: Changed `components: List[ComponentDef]` to `components: Map[String, ComponentFactory]` for proper registration pattern.
|
||||
</action>
|
||||
<verify>
|
||||
Run `./mill summercms.compile` - all component and plugin files compile.
|
||||
|
||||
Verify the types work together by checking in Mill REPL:
|
||||
```bash
|
||||
./mill -i summercms.console
|
||||
```
|
||||
|
||||
Then:
|
||||
```scala
|
||||
import component._
|
||||
import zio._
|
||||
|
||||
// Test schema parsing
|
||||
val yaml = """
|
||||
name: Test Component
|
||||
description: A test component
|
||||
properties:
|
||||
title:
|
||||
type: String
|
||||
title: Title
|
||||
default: "Hello"
|
||||
"""
|
||||
println(ComponentSchema.parse(yaml))
|
||||
// Should print Right(ComponentSchema(Test Component,A test component,None,Map(title -> PropertyDef(...))))
|
||||
|
||||
:quit
|
||||
```
|
||||
</verify>
|
||||
<done>SummerComponent trait with lifecycle hooks, ComponentFactory, ComponentManager service, PluginRegistration updated to use Map[String, ComponentFactory]</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `./mill summercms.compile` succeeds with all new dependencies and types
|
||||
2. ComponentSchema.parse(validYaml) returns Right(schema)
|
||||
3. PropertyDef parses type, title, default, validation from YAML
|
||||
4. SummerComponent trait has init and onRun lifecycle hooks
|
||||
5. ComponentManager.live provides working service
|
||||
6. PluginRegistration.components is Map[String, ComponentFactory]
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- build.mill has Pebble, zio-http-htmx, ScalaTags dependencies
|
||||
- YAML property schemas parse with all WinterCMS-compatible types
|
||||
- SummerComponent trait defines alias, details, propertySchema, properties, init, onRun
|
||||
- ComponentManager can register and resolve components by alias
|
||||
- PluginRegistration supports component registration via Map
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/03-component-system/03-01-SUMMARY.md`
|
||||
</output>
|
||||
778
.planning/phases/03-component-system/03-02-PLAN.md
Normal file
778
.planning/phases/03-component-system/03-02-PLAN.md
Normal file
@@ -0,0 +1,778 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user