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

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
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
true
truths artifacts key_links
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
path provides contains
summercms/src/component/SummerComponent.scala Base trait for all components with lifecycle trait SummerComponent
path provides exports
summercms/src/component/ComponentManager.scala Service managing all registered components
ComponentManager
path provides contains
summercms/src/component/PropertyDef.scala YAML property definition types case class PropertyDef
path provides contains
summercms/src/component/ComponentSchema.scala YAML component schema parser object ComponentSchema
from to via pattern
summercms/src/component/ComponentManager.scala summercms/src/plugin/PluginManager.scala collects components from plugin registrations PluginManager.getRegistration
from to via pattern
summercms/src/component/ComponentSchema.scala circe-yaml YAML parsing parser.parse
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

<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",
Run `./mill summercms.compile` - dependencies resolve successfully build.mill contains Pebble 4.1.1, zio-http-htmx, ScalaTags dependencies Task 2: Create component property types and schema parser 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 Create `summercms/src/component/` directory with these files:

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
Run `./mill summercms.compile` - all component types compile without errors PropertyValue ADT, PropertyDef with validation, PropertyType enum, ComponentSchema parser, ComponentError ADT Task 3: Create SummerComponent trait and ComponentManager service summercms/src/component/SummerComponent.scala summercms/src/component/ComponentFactory.scala summercms/src/component/ComponentManager.scala summercms/src/plugin/PluginRegistration.scala **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:

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
SummerComponent trait with lifecycle hooks, ComponentFactory, ComponentManager service, PluginRegistration updated to use Map[String, ComponentFactory] 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]

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