PureYAML/docs/rules/components.md

10 KiB

Component System Architecture Rules

Component-system rules for the planned PureYAML native editor. The editor's UI is built from reusable, registry-driven components. This file consolidates the component protocol, the package hierarchy, the registry, and the one-component-per-file discipline.

One component per file

  • Each UI component lives in its own component package, NEVER in feature packages or screens.
  • ONE component per file; the file name equals the component name.
  • Every component conforms to the Component protocol from the Components package.

Core rules

Rule 1: Three-package hierarchy

Use this strict layering:

Components (core infrastructure - ZERO dependencies)
    ↓
SharedComponents (hot reload - depends on hot-reload + file-watch tools only)
    ↓
AppComponents (app-specific - depends on Components + theme + font packages)
    ↓
AllComponents (optional aggregator for previews - depends on Components + AppComponents)

Rule 2: Components package foundation

Create the Components package FIRST with the core infrastructure:

  • MUST contain: the Component protocol, AnyComponent, ComponentsRegistry, ComponentFactory.
  • MUST contain: bundle management, registration interfaces, and the component-list model/view.
  • MUST include a components.json configuration file, processed with .process().
  • MUST have ZERO dependencies (foundation layer).

Rule 3: SharedComponents dependencies

Depend ONLY on hot-reload tooling:

  • A hot-reload injection library and a file-watching library.
  • NEVER depend on UI packages (theme, font). Mixing them in breaks the hot-reload layer's independence from the UI foundation.

Rule 4: AppComponents dependencies

Depend on the foundation packages:

  • Components (core system)
  • The theme package (colors, styles)
  • The font package (typography)

Rule 5: Component registration

Register components via the registry:

  • All components conform to the Component protocol from the Components package.
  • Registration happens in a per-package *ComponentsRegistration.swift.
  • Register external components through the shared registry.

The Component protocol

import Foundation
import SwiftUI

public protocol ComponentData: Hashable, Decodable, Sendable {}
public typealias ComponentKind = String

public protocol Component: Sendable {
    associatedtype Data: ComponentData
    associatedtype ViewBody: View

    var data: Data { get }

    /// Build the SwiftUI view for this component.
    /// Runs on the main actor because SwiftUI view construction
    /// and state are main-actor isolated.
    @MainActor
    func make() -> ViewBody

    static var kind: ComponentKind { get }
    init(data: Data)
}

public extension Component {
    /// Auto-generates kind from the type name (e.g. "TilePreviewComponent" → "tilepreview").
    @inline(__always)
    static var kind: ComponentKind {
        let fullName = String(reflecting: Self.self)
        let parts = fullName.split(separator: ".")
        let typePath = parts.dropFirst().joined(separator: ".")
        return typePath
            .replacingOccurrences(of: "Component", with: "")
            .lowercased()
    }

    /// Register this component in the registry.
    static func register(in registry: ComponentsRegistry) {
        let componentType = Self.self
        let dataType = Self.Data.self

        registry.decoders[kind] = { decoder in
            let data = try decoder.decode(dataType, forKey: .payload)
            return ComponentFactory(data: data, type: componentType)
        }
    }
}

AnyComponent (type-erased wrapper)

AnyComponent is a type-erased wrapper struct, not a protocol. Concrete components conform to Component; AnyComponent wraps any of them for storage and rendering.

@MainActor
public struct AnyComponent: Identifiable {
    private let component: any Component
    public var id: UUID = .init()
    public let kind: ComponentKind

    private let _make: () -> AnyView

    public init<ComponentType>(_ component: ComponentType) where ComponentType: Component {
        self.component = component
        kind = ComponentType.kind
        _make = { AnyView(component.make()) }
    }

    public var contentView: AnyView { _make() }
}

ComponentFactory (Sendable bridge)

ComponentFactory is Sendable so component data can be decoded off the main thread, then rendered on the main actor.

public struct ComponentFactory: Sendable {
    public let kind: ComponentKind

    private let _makeRenderable: @MainActor () -> AnyComponent

    public init<C: Component>(data: C.Data, type: C.Type) {
        kind = C.kind
        _makeRenderable = {
            let instance = C(data: data)
            return AnyComponent(instance)
        }
    }

    @MainActor
    public func makeRenderable() -> AnyComponent {
        _makeRenderable()
    }
}

ComponentsRegistry

The registry maps a component kind to a decoder that produces a ComponentFactory. JSON describing a component carries a kind plus a payload, and the registry decodes the right concrete data type.

public final class ComponentsRegistry {
    public typealias ComponentDecoder =
        @Sendable (KeyedDecodingContainer<Container.CodingKeys>) throws -> ComponentFactory

    public var decoders: [ComponentKind: ComponentDecoder] = [:]

    public struct Container: Decodable {
        public enum CodingKeys: CodingKey { case kind, payload }
        public struct ComponentNotFound: Error { public var kind: ComponentKind }
        public struct DecodersNotFound: Error {}

        public static let decodersKey = CodingUserInfoKey(rawValue: "ComponentDecoders")!
        public let factory: ComponentFactory

        public init(from decoder: Decoder) throws {
            guard let decoders = decoder.userInfo[Self.decodersKey] as? [ComponentKind: ComponentDecoder] else {
                throw DecodersNotFound()
            }
            let container = try decoder.container(keyedBy: CodingKeys.self)
            let kind = try container.decode(ComponentKind.self, forKey: .kind)
            guard let decodeForKind = decoders[kind] else {
                throw ComponentNotFound(kind: kind)
            }
            factory = try decodeForKind(container)
        }
    }

    public init() {}

    /// Decode JSON into ComponentFactories (safe off the main thread).
    public func decodeFactories(from data: Data) -> [ComponentFactory] {
        do {
            let decoder = JSONDecoder()
            decoder.userInfo[Container.decodersKey] = decoders
            let containers = try decoder.decode([Container].self, from: data)
            return containers.map(\.factory)
        } catch {
            print("Unable to parse \(error)")
            return []
        }
    }
}

Concrete component example

One component per file. Define the data struct, store it, implement make(), and provide a preview.

import Components
import SwiftUI

public struct TilePreviewComponent: Component {
    public struct Data: ComponentData {
        public var title: String
        public var subtitle: String
        public var isActive: Bool = true

        public init(title: String, subtitle: String, isActive: Bool = true) {
            self.title = title
            self.subtitle = subtitle
            self.isActive = isActive
        }
    }

    public var data: Data

    public init(data: Data) {
        self.data = data
    }

    public func make() -> some View {
        TilePreviewContent(data: data)
    }
}

// Separate view struct for the actual UI.
struct TilePreviewContent: View {
    var data: TilePreviewComponent.Data
    @Environment(\.appTheme) private var theme

    var body: some View {
        // card implementation using theme.colors and the font package
        EmptyView()
    }
}

#Preview {
    TilePreviewComponent(data: .init(
        title: "Hero Tile",
        subtitle: "Top of the canvas"
    )).make()
}

components.json

Describes the components and sample payloads used by previews and the component gallery. Lives in the Components package, processed with .process().

{
  "components": [
    {
      "kind": "tile-preview",
      "category": "Display",
      "description": "Displays a tile preview with status",
      "payload": {
        "title": "Hero Tile",
        "subtitle": "Top of the canvas",
        "isActive": true
      }
    }
  ]
}

Registration

import Components

/// Register a package's components in the registry.
public func registerTileComponents(in registry: ComponentsRegistry) {
    TilePreviewComponent.register(in: registry)
    // additional components...
}

// In app initialization:
let registry = ComponentsRegistry()
registerTileComponents(in: registry)

AllComponents aggregator

An optional umbrella package that re-exports Components and AppComponents for convenient imports.

@_exported import Components
@_exported import AppComponents
  • USE only in preview/demo apps and the component gallery.
  • DO NOT use in production features or main app targets; be explicit with imports there.

Common mistakes

  • Creating AppComponents without the core Components package.
  • Adding UI dependencies (theme, font) to Components (it MUST have zero dependencies).
  • Adding UI dependencies to SharedComponents (hot reload only).
  • Importing the broad AllComponents aggregator inside production features.
  • Over-specific package naming. Keep app-specific components in AppComponents, not a narrowly named package.

Checklist

  • Components package exists with zero dependencies, created first
  • Components contains the protocol, AnyComponent, registry, factory, list types
  • Components includes components.json, processed with .process()
  • SharedComponents depends ONLY on hot-reload + file-watch tooling
  • AppComponents depends on Components + theme + font
  • AppComponents holds production components only
  • AllComponents, if present, used only in preview/demo apps
  • All components conform to the Component protocol
  • ONE component per file; file name equals component name
  • Registration goes through the shared registry
  • Resources use .process() not .copy()
  • The component layer follows the strict hierarchy