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

653 lines
21 KiB
Markdown

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