Joy DOM

Overview

Render Joy DOM documents as native SwiftUI views via a Yoga-based flex engine.

JoyDOM (Swift package) decodes a Joy DOM JSON document into a Spec model and renders it as a live SwiftUI view tree. Layout is delegated to the Yoga-based FlexLayout engine, so flex behavior matches the web spec. UIKit and WebKit are available per custom component for cases SwiftUI can't cover.

Status: pre-release

The package builds and ships a snapshot-test suite, but it isn't published to a registry yet. Its flex engine comes from the j0yhq/flexbox-swift repo, pinned by revision in Package.swift; folding FlexLayout into this monorepo is a planned cleanup. Consume it today by adding the local package at swift/.

Render a document

JoyDOMView is the entry point. Build a ComponentRegistry and pass it alongside the Spec:

import SwiftUI
import JoyDOM

// ComponentRegistry.shared is empty by design. Seed a registry with the
// built-in factories (div / p / h1–h6 / span / img) via withDefaultPrimitives(),
// or every built-in node renders as a placeholder.
let registry = ComponentRegistry().withDefaultPrimitives()

struct InvoiceScreen: View {
    let spec: Spec

    var body: some View {
        JoyDOMView(spec: spec, registry: registry)
            .viewport(Viewport(width: 520))   // drives breakpoint resolution
            .padding()
    }
}

JoyDOMView walks the tree, resolves the style cascade, and renders each node: flex containers through a FlexLayout container, text nodes through SwiftUI Text, and img nodes through AsyncImage. Without a Viewport, only document-level styles apply — breakpoints need the width, orientation, and print flag it carries.

Custom components

Register a handler keyed by the kebab-case node type. The factory receives the resolved props and an events sink, and returns a ComponentBody.custom { … } for SwiftUI:

registry.register("contact-button") { props, events in
    .custom {
        Button(props.string("label") ?? "") {
            events.emit("tap")
        }
    }
}

register is last-wins and chainable, so a custom "img" overrides the built-in one. props.string("key") reads a scalar; props.value("key") returns the raw JSON value. For UIKit or web content, return .uiKit(make:update:) or .webView(html:baseURL:onMessage:) instead of .custom.

Events

Built-in nodes carry action bindings; custom components call events.emit(_:). Both bubble to the handlers you attach with .onEvent:

JoyDOMView(spec: spec, registry: registry)
    .onEvent("checkout") { event in
        print("checkout", event.payload)   // [String: JSONValue]
    }

Loading documents

Spec is Codable, so decode the JSON directly:

let url = Bundle.main.url(forResource: "card", withExtension: "json")!
let data = try Data(contentsOf: url)
let spec = try JSONDecoder().decode(Spec.self, from: data)

The model matches the format @joy-dom/spec defines, so a document validated on the web decodes unchanged here.

Source layout

The Swift sources live under swift/Sources/:

  • Sources/JoyDOM — the renderer: Spec model, style cascade, ComponentRegistry, and the SwiftUI views.
  • Sources/JoyDOMSampleSpecs — per-property JSON sample specs plus withTestAssetImageFactory(), which resolves test-assets://<name> to PNGs under assets/images/. This is a dev/test target, not a shipped product.
  • Tests/JoyDOMTests — unit and snapshot tests with baselines under PropertyCoverage/**/__Snapshots__.

Local development

From swift/:

swift build
swift test

Snapshot baselines for the per-property coverage walk are excluded from SwiftPM's resource handling via Package.swift.

Where next

  • Specification — properties and breakpoints supported across renderers.

On this page