Conversation
There was a problem hiding this comment.
Pull request overview
Initial import of JSONRenderSwift, a Swift Package that renders AI-generated JSON UI specs into native SwiftUI views, with a macro-based component catalog and schema generation tooling.
Changes:
- Adds core models (Spec/UIElement/PropValue/JSONValue), resolution (templates/conditions), and rendering pipeline (renderer, repeat, registry, built-in components).
- Introduces state system with backend routing (in-memory + SwiftData persisted) and an action execution layer.
- Adds schema generation tooling (macro + build plugin + CLI) plus a comprehensive test suite and expanded README/CI scaffolding.
Reviewed changes
Copilot reviewed 68 out of 70 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| Tests/JSONRenderSwiftTests/State/StateStoreTests.swift | Unit tests for StateStore get/set/remove/update + backend routing |
| Tests/JSONRenderSwiftTests/Resolution/VisibilityEvaluatorTests.swift | Tests for visibility evaluation logic |
| Tests/JSONRenderSwiftTests/Resolution/TemplateInterpolatorTests.swift | Tests for ${...} template interpolation |
| Tests/JSONRenderSwiftTests/Resolution/PropResolverTests.swift | Tests for resolving PropValue expressions and bindings |
| Tests/JSONRenderSwiftTests/Renderer/RendererTests.swift | Component-level + integration renderer tests (ViewInspector) |
| Tests/JSONRenderSwiftTests/Models/VisibilityConditionTests.swift | Codable tests for visibility condition model |
| Tests/JSONRenderSwiftTests/Models/SpecDecodingTests.swift | Codable tests for Spec/UIElement decoding |
| Tests/JSONRenderSwiftTests/Models/PropValueDecodingTests.swift | Codable tests for PropValue decoding/encoding |
| Tests/JSONRenderSwiftTests/Models/JSONPointerTests.swift | Tests for RFC6901 pointer parsing/resolve/set/remove |
| Tests/JSONRenderSwiftTests/Models/AnyCodableTests.swift | Tests for JSONValue decoding, truthiness, display string |
| Tests/JSONRenderSwiftTests/Actions/ActionExecutorTests.swift | Tests for built-in actions + custom handler |
| Tests/JSONRenderMacroTests/ComponentMacroTests.swift | Macro expansion tests for @Component |
| Sources/SchemaGeneratorTool/main.swift | CLI that scans sources for @Component and emits schema JSON |
| Sources/JSONRenderSwift/State/StateStore.swift | Central observable state store + backend routing |
| Sources/JSONRenderSwift/State/StateBackend.swift | State backend protocol |
| Sources/JSONRenderSwift/State/PersistedStateEntry.swift | SwiftData model for persisted state entries |
| Sources/JSONRenderSwift/State/PersistedStateBackend.swift | SwiftData-backed state backend implementation |
| Sources/JSONRenderSwift/State/LocalStateBackend.swift | In-memory backend + deepMerge helper |
| Sources/JSONRenderSwift/Resolution/VisibilityEvaluator.swift | Visibility evaluation + ResolutionContext |
| Sources/JSONRenderSwift/Resolution/TemplateInterpolator.swift | Template interpolation implementation |
| Sources/JSONRenderSwift/Resolution/PropResolver.swift | PropValue → JSONValue resolution + bindings extraction |
| Sources/JSONRenderSwift/Renderer/RepeatScope.swift | Repeat context environment plumbing |
| Sources/JSONRenderSwift/Renderer/RepeatRenderer.swift | Repeat rendering for array state paths |
| Sources/JSONRenderSwift/Renderer/JSONRenderer.swift | Top-level SwiftUI renderer view |
| Sources/JSONRenderSwift/Renderer/ElementRenderer.swift | Recursive element rendering + action dispatch |
| Sources/JSONRenderSwift/Preview/PreviewDemos.swift | Demo previews/spec examples |
| Sources/JSONRenderSwift/Models/VisibilityCondition.swift | Visibility condition model + decoding operators |
| Sources/JSONRenderSwift/Models/Spec.swift | Spec/UIElement model definitions |
| Sources/JSONRenderSwift/Models/RepeatConfig.swift | Repeat configuration model |
| Sources/JSONRenderSwift/Models/PropValue.swift | PropValue union + Codable implementation |
| Sources/JSONRenderSwift/Models/JSONPointer.swift | JSON pointer implementation |
| Sources/JSONRenderSwift/Models/AnyCodable.swift | JSONValue type-erased JSON representation |
| Sources/JSONRenderSwift/Models/ActionBinding.swift | Action binding model (single or multiple) |
| Sources/JSONRenderSwift/JSONRenderSwift.swift | Package “surface” documentation stub |
| Sources/JSONRenderSwift/Components/JRZStack.swift | Built-in ZStack component |
| Sources/JSONRenderSwift/Components/JRVStack.swift | Built-in VStack component |
| Sources/JSONRenderSwift/Components/JRToggle.swift | Built-in Toggle component w/ binding |
| Sources/JSONRenderSwift/Components/JRTextField.swift | Built-in TextField component w/ binding |
| Sources/JSONRenderSwift/Components/JRText.swift | Built-in Text component + parsing helpers |
| Sources/JSONRenderSwift/Components/JRSpacer.swift | Built-in Spacer component |
| Sources/JSONRenderSwift/Components/JRSlider.swift | Built-in Slider component w/ binding |
| Sources/JSONRenderSwift/Components/JRSection.swift | Built-in Section component |
| Sources/JSONRenderSwift/Components/JRProgressView.swift | Built-in ProgressView component |
| Sources/JSONRenderSwift/Components/JRList.swift | Built-in List component |
| Sources/JSONRenderSwift/Components/JRLink.swift | Built-in Link component |
| Sources/JSONRenderSwift/Components/JRLabel.swift | Built-in Label component |
| Sources/JSONRenderSwift/Components/JRImage.swift | Built-in Image/AsyncImage component |
| Sources/JSONRenderSwift/Components/JRHStack.swift | Built-in HStack component |
| Sources/JSONRenderSwift/Components/JRForm.swift | Built-in Form component |
| Sources/JSONRenderSwift/Components/JRDivider.swift | Built-in Divider component |
| Sources/JSONRenderSwift/Components/JRCard.swift | Built-in Card component + style handling |
| Sources/JSONRenderSwift/Components/JRButton.swift | Built-in Button component + press event |
| Sources/JSONRenderSwift/Components/JRBadge.swift | Built-in Badge component |
| Sources/JSONRenderSwift/Components/BuiltInComponents.swift | Registers built-ins into registry |
| Sources/JSONRenderSwift/Catalog/RenderableComponent.swift | Bridge protocol for @Component views into renderer |
| Sources/JSONRenderSwift/Catalog/ComponentRegistry.swift | Component registry + environment wiring + schema export |
| Sources/JSONRenderSwift/Actions/ActionExecutor.swift | Action dispatch + built-in state actions |
| Sources/JSONRenderMacros/Plugin.swift | Macro plugin entry point |
| Sources/JSONRenderMacros/ComponentMacro.swift | @Component macro implementation |
| Sources/JSONRenderClient/Prop.swift | @Prop property wrapper |
| Sources/JSONRenderClient/ComponentMacro.swift | Public macro declaration for clients |
| Sources/JSONRenderClient/ComponentDefinition.swift | ComponentDefinition + prop metadata types |
| Sources/JSONRenderClient/ComponentCatalog.swift | Component catalog protocol |
| README.md | Full documentation for spec format, components, state, schema |
| Plugins/JSONRenderSchemaPlugin/JSONRenderSchemaPlugin.swift | Build tool plugin to generate components.json |
| Package.swift | SwiftPM manifest (targets, macros, tool, plugin, tests) |
| Package.resolved | Dependency lockfile |
| .vscode/launch.json | VS Code debug configs |
| .gitignore | Ignore build/artifact files |
| .github/workflows/ci.yml | CI workflow (build + test) |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// Get a value at a JSON Pointer path. | ||
| public func get(_ path: String) -> JSONValue? { | ||
| JSONPointer(path).resolve(in: state) | ||
| } |
There was a problem hiding this comment.
State mutations are locked, but rebuildState() reads defaultBackend.stateSlice / backend.stateSlice and writes state without holding the lock, and get(_:) reads state without synchronization. Since StateStore is @unchecked Sendable, this can lead to data races; consider protecting rebuildState and get with the same lock (or making StateStore @MainActor).
|
|
||
| ## Built-in Components | ||
|
|
||
| 18 components ship out of the box. All are extensible. |
There was a problem hiding this comment.
README says "18 components ship out of the box", but BuiltInComponents.registerAll registers 19 components (including Form and Section), and tests assert 19. Update the documented count to match the actual registry.
| 18 components ship out of the box. All are extensible. | |
| 19 components ship out of the box. All are extensible. |
| stateSlice = deepMerge(base: stateSlice, overlay: state) | ||
| } |
There was a problem hiding this comment.
This example uses deepMerge(...), but deepMerge is declared internal in LocalStateBackend.swift, so package consumers can’t call it. Either make deepMerge public (and document it), or update the README to show an inlined merge implementation for custom backends.
| stateSlice = deepMerge(base: stateSlice, overlay: state) | |
| } | |
| stateSlice = merge(stateSlice, with: state) | |
| } | |
| private func merge(_ base: JSONValue, with overlay: JSONValue) -> JSONValue { | |
| guard case let .object(baseObject) = base, | |
| case let .object(overlayObject) = overlay else { | |
| return overlay | |
| } | |
| var merged = baseObject | |
| for (key, overlayValue) in overlayObject { | |
| if let baseValue = merged[key] { | |
| merged[key] = merge(baseValue, with: overlayValue) | |
| } else { | |
| merged[key] = overlayValue | |
| } | |
| } | |
| return .object(merged) | |
| } |
| | `setState` | `path`, `value` | Set a value at a state path | | ||
| | `pushState` | `path`, `value` | Append to a state array | | ||
| | `removeState` | `path`, `index` | Remove item from a state array by index | | ||
| | `toggleState` | `path` | Toggle a boolean state value | |
There was a problem hiding this comment.
The "Built-in actions" list omits incrementState and decrementState, which are implemented in ActionExecutor.registerBuiltIns() and used in the previews. Consider documenting them here (including the optional amount param).
| | `toggleState` | `path` | Toggle a boolean state value | | |
| | `toggleState` | `path` | Toggle a boolean state value | | |
| | `incrementState` | `path`, `amount` (optional) | Increment a numeric state value | | |
| | `decrementState` | `path`, `amount` (optional) | Decrement a numeric state value | |
| for backend in backends { | ||
| let prefix = backend.pathPrefix | ||
| if !prefix.isEmpty && path.hasPrefix(prefix) && prefix.count > bestLen { | ||
| best = backend | ||
| bestLen = prefix.count |
There was a problem hiding this comment.
routeBackend(for:) uses path.hasPrefix(prefix), which can mis-route when a prefix is a substring of another path segment (e.g. "/local" also matches "/locality"). Consider matching on path-segment boundaries (exact match or prefix + "/").
| ForEach(Array(items.enumerated()), id: \.offset) { index, item in | ||
| let scope = RepeatScopeValue( | ||
| item: item, | ||
| index: index, | ||
| basePath: "\(config.statePath)/\(index)" |
There was a problem hiding this comment.
RepeatConfig.key is ignored here, so repeated rows won’t have stable identity when items are inserted/removed/reordered. Prefer using the key field (when present) to derive a stable id from each item, falling back to index only when necessary.
| case .bindItemRef(let field): | ||
| if let basePath = context.repeatItem != nil ? "" : nil { | ||
| // For $bindItem, construct the full state path from repeat context | ||
| // This requires the basePath from the repeat scope | ||
| if field.isEmpty { |
There was a problem hiding this comment.
This $bindItem binding-path construction uses an empty-string basePath, which yields incorrect/ambiguous bindings (and ignores the repeat scope’s base path). Consider removing this overload or threading a real repeat base path through ResolutionContext.
| public init(key: String, value: JSONValue) { | ||
| self.key = key | ||
| self.jsonData = (try? JSONEncoder().encode(value)) ?? Data() | ||
| } |
There was a problem hiding this comment.
Encoding failures are silently swallowed by writing Data() (and later decoding back to .null), which can hide persistence corruption and overwrite valid values. Consider surfacing/logging encoding errors and avoiding Data() as a sentinel.
| let modified: any View = if resizable { | ||
| image.resizable() | ||
| .aspectRatio(contentMode: parseContentMode()) | ||
| .frame( | ||
| width: ctx.resolvedProps["width"]?.doubleValue.map { CGFloat($0) }, | ||
| height: ctx.resolvedProps["height"]?.doubleValue.map { CGFloat($0) } | ||
| ) | ||
| } else { | ||
| image.frame( | ||
| width: ctx.resolvedProps["width"]?.doubleValue.map { CGFloat($0) }, | ||
| height: ctx.resolvedProps["height"]?.doubleValue.map { CGFloat($0) } | ||
| ) | ||
| } | ||
|
|
||
| if let color { | ||
| AnyView(modified).foregroundColor(parseColor(color)) | ||
| } else { | ||
| AnyView(modified) | ||
| } |
There was a problem hiding this comment.
Using let modified: any View = ... and then type-erasing later is likely to fail compilation because any View existentials typically can’t be passed to AnyView’s generic initializer. Prefer building the conditional view directly in a @ViewBuilder, or type-erase each branch to AnyView explicitly.
| let modified: any View = if resizable { | |
| image.resizable() | |
| .aspectRatio(contentMode: parseContentMode()) | |
| .frame( | |
| width: ctx.resolvedProps["width"]?.doubleValue.map { CGFloat($0) }, | |
| height: ctx.resolvedProps["height"]?.doubleValue.map { CGFloat($0) } | |
| ) | |
| } else { | |
| image.frame( | |
| width: ctx.resolvedProps["width"]?.doubleValue.map { CGFloat($0) }, | |
| height: ctx.resolvedProps["height"]?.doubleValue.map { CGFloat($0) } | |
| ) | |
| } | |
| if let color { | |
| AnyView(modified).foregroundColor(parseColor(color)) | |
| } else { | |
| AnyView(modified) | |
| } | |
| if resizable { | |
| if let color { | |
| image.resizable() | |
| .aspectRatio(contentMode: parseContentMode()) | |
| .frame( | |
| width: ctx.resolvedProps["width"]?.doubleValue.map { CGFloat($0) }, | |
| height: ctx.resolvedProps["height"]?.doubleValue.map { CGFloat($0) } | |
| ) | |
| .foregroundColor(parseColor(color)) | |
| } else { | |
| image.resizable() | |
| .aspectRatio(contentMode: parseContentMode()) | |
| .frame( | |
| width: ctx.resolvedProps["width"]?.doubleValue.map { CGFloat($0) }, | |
| height: ctx.resolvedProps["height"]?.doubleValue.map { CGFloat($0) } | |
| ) | |
| } | |
| } else { | |
| if let color { | |
| image.frame( | |
| width: ctx.resolvedProps["width"]?.doubleValue.map { CGFloat($0) }, | |
| height: ctx.resolvedProps["height"]?.doubleValue.map { CGFloat($0) } | |
| ) | |
| .foregroundColor(parseColor(color)) | |
| } else { | |
| image.frame( | |
| width: ctx.resolvedProps["width"]?.doubleValue.map { CGFloat($0) }, | |
| height: ctx.resolvedProps["height"]?.doubleValue.map { CGFloat($0) } | |
| ) | |
| } | |
| } |
| // Merge resolved params from the action binding with emitted params | ||
| let actionContext = ResolutionContext( | ||
| state: store.state, | ||
| repeatItem: nil, | ||
| repeatIndex: nil | ||
| ) |
There was a problem hiding this comment.
Action-binding params are resolved with a ResolutionContext that hard-codes repeatItem/repeatIndex to nil, so $item / $index can’t be used inside action params for repeated elements. Consider propagating the current repeat scope into actionContext (e.g. using the same repeatItem/repeatIndex as the element render context).
# Conflicts: # README.md
Implement the json-render from vercel