Mirror the shared Swift rules canon (rules-swift, commit 4ebb892) into docs/rules-swift/ as a read-only reference alongside the repo's tailored docs/rules/. PROVENANCE.md records the source, commit, and what was included or excluded; the upstream repo's own VCS, CI, and lint tooling is omitted as machinery rather than rule content. Exclude the vendored tree from the style gate (scripts/check-style.sh) and the pre-commit hook: the canon's enforcement docs quote the forbidden attribution phrases to define them, the same reason the repo's own enforcement files are already excluded.
328 lines
10 KiB
Markdown
328 lines
10 KiB
Markdown
# Components
|
|
|
|
How a Tiledown-style native editor builds its UI 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
|
|
|
|
```swift
|
|
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.
|
|
|
|
```swift
|
|
@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.
|
|
|
|
```swift
|
|
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.
|
|
|
|
```swift
|
|
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.
|
|
|
|
```swift
|
|
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()`.
|
|
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```swift
|
|
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.
|
|
|
|
```swift
|
|
@_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
|