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
21 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 | 01 | execute | 1 |
|
true |
|
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
<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 @build.mill @summercms/src/plugin/PluginManager.scala @summercms/src/plugin/PluginRegistration.scala @summercms/src/plugin/SummerPlugin.scala Task 1: Add component system dependencies to build.mill build.mill Add the following dependencies to build.mill mvnDeps after the existing Flyway/logging dependencies:// 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:
// 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",
ComponentError.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:
package component
/** Component metadata from YAML schema */
case class ComponentDetails(
name: String,
description: String,
icon: Option[String] = None
)
PropertyValue.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:
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:
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
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:
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:
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.
Run ./mill summercms.compile - all component and plugin files compile.
Verify the types work together by checking in Mill REPL:
./mill -i summercms.console
Then:
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
<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>