Skip to content

Commit ccca999

Browse files
alexey1312claude
andauthored
fix: Code Connect using variant node IDs instead of component set node IDs (#55)
* fix(icons): use component set node IDs for Code Connect instead of variant node IDs Figma Code Connect API requires top-level component or component set node IDs, but ExFig was passing variant node IDs (e.g. RTL=Off) which caused publish errors: "node is not a top level component or component set" Add `codeConnectNodeId` computed property on Component that returns the parent component set nodeId for variants and own nodeId for regular components. Apply it to AssetMetadata and ImagePack construction in all code paths. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: update cluade.md * fix: after review --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dcede9e commit ccca999

3 files changed

Lines changed: 71 additions & 11 deletions

File tree

CLAUDE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,10 +247,20 @@ as string literals in ExFigCore inits; use shared constants only within ExFigCLI
247247
### RTL Detection Design
248248

249249
- `Component.iconName`: uses `containingComponentSet.name` for variants, own `name` otherwise
250+
- `Component.codeConnectNodeId`: uses `containingComponentSet.nodeId` for variants, own `nodeId` otherwise (Figma Code Connect rejects variant node IDs)
250251
- `Component.defaultRTLProperty = "RTL"`: shared constant in ExFigCLI for the magic string
251252
- PNG images intentionally do NOT carry `isRTL` — raster images skip mirroring by design
252253
- `buildPairedComponents` must use `iconName` (not `name`) — variant `name` is `"RTL=Off"`, not the icon name
253254

255+
### Modifying Node ID Logic (AssetMetadata / ImagePack)
256+
257+
When changing how node IDs are resolved (e.g., `codeConnectNodeId`), update ALL construction sites in `ImageLoaderBase.swift`:
258+
259+
1. `AssetMetadata` in `fetchImageComponentsWithGranularCache` (~line 156)
260+
2. `AssetMetadata` in `fetchImageComponentsWithGranularCacheAndPairing` (~line 220)
261+
3. `ImagePack` primaryNodeId in `loadVectorImages` (vector/SVG path)
262+
4. `ImagePack` primaryNodeId in `loadPNGImages` (raster path)
263+
254264
### Adding a CLI Command
255265

256266
1. Create `Sources/ExFigCLI/Subcommands/NewCommand.swift` implementing `AsyncParsableCommand`

Sources/ExFigCLI/Loaders/ImageLoaderBase.swift

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,10 @@ class ImageLoaderBase: @unchecked Sendable {
152152
)
153153

154154
// Build metadata for all assets (for Code Connect and template generation)
155-
// Use iconName to get the real name (component set name for variants)
156-
let allAssetMetadata = allComponents.map { nodeId, component in
157-
AssetMetadata(name: component.iconName, nodeId: nodeId, fileId: fileId)
155+
// Use component set data for variants: iconName for name, codeConnectNodeId for node ID.
156+
// Figma Code Connect rejects variant node IDs — requires top-level component/component set IDs.
157+
let allAssetMetadata = allComponents.map { _, component in
158+
AssetMetadata(name: component.iconName, nodeId: component.codeConnectNodeId, fileId: fileId)
158159
}
159160

160161
guard let manager = granularCacheManager, !allComponents.isEmpty else {
@@ -216,8 +217,8 @@ class ImageLoaderBase: @unchecked Sendable {
216217
let allComponents = try await fetchImageComponents(
217218
fileId: fileId, frameName: frameName, pageName: pageName, filter: filter, rtlProperty: rtlProperty
218219
)
219-
let allAssetMetadata = allComponents.map { nodeId, component in
220-
AssetMetadata(name: component.iconName, nodeId: nodeId, fileId: fileId)
220+
let allAssetMetadata = allComponents.map { _, component in
221+
AssetMetadata(name: component.iconName, nodeId: component.codeConnectNodeId, fileId: fileId)
221222
}
222223

223224
guard let manager = granularCacheManager, !allComponents.isEmpty else {
@@ -505,7 +506,7 @@ class ImageLoaderBase: @unchecked Sendable {
505506
isRTL: component.useRTL(rtlProperty: rtlProperty)
506507
)
507508
}
508-
let primaryNodeId = components.first?.0
509+
let primaryNodeId = components.first.map(\.1.codeConnectNodeId)
509510
return ImagePack(
510511
name: packName,
511512
images: packImages,
@@ -645,7 +646,7 @@ class ImageLoaderBase: @unchecked Sendable {
645646
)
646647
}
647648
}
648-
let primaryNodeId = components.first?.0
649+
let primaryNodeId = components.first.map(\.1.codeConnectNodeId)
649650
return ImagePack(
650651
name: packName,
651652
images: packImages,
@@ -892,6 +893,13 @@ public extension Component {
892893
containingFrame.containingComponentSet?.name ?? name
893894
}
894895

896+
/// Node ID for Code Connect: component set ID for variants, own ID otherwise.
897+
/// Figma Code Connect rejects variant node IDs — use this to avoid
898+
/// "node is not a top level component or component set" errors.
899+
var codeConnectNodeId: String {
900+
containingFrame.containingComponentSet?.nodeId ?? nodeId
901+
}
902+
895903
/// Extracts the RTL variant value from the component name (e.g. "Off" from "RTL=Off").
896904
/// Parses "Property=Value, Property2=Value2" format used by Figma variant components.
897905
func rtlVariantValue(propertyName: String) -> String? {

Tests/ExFigTests/Loaders/ComponentRTLTests.swift

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,41 @@ final class ComponentRTLTests: XCTestCase {
156156
XCTAssertFalse(component.useRTL(rtlProperty: ""))
157157
}
158158

159+
// MARK: - codeConnectNodeId
160+
161+
func testCodeConnectNodeId_variantComponent_usesComponentSetNodeId() {
162+
let component = makeComponent(
163+
name: "RTL=Off",
164+
componentSetName: "arrow-left"
165+
)
166+
// Component set nodeId is "99:0", own nodeId is "1:0"
167+
XCTAssertEqual(component.codeConnectNodeId, "99:0")
168+
}
169+
170+
func testCodeConnectNodeId_regularComponent_usesOwnNodeId() {
171+
let component = makeComponent(name: "arrow-left")
172+
XCTAssertEqual(component.codeConnectNodeId, "1:0")
173+
}
174+
175+
func testCodeConnectNodeId_variantWithMultipleProperties_usesComponentSetNodeId() {
176+
let component = makeComponent(
177+
name: "State=Default, RTL=Off",
178+
componentSetName: "new-orders"
179+
)
180+
XCTAssertEqual(component.codeConnectNodeId, "99:0")
181+
}
182+
183+
func testCodeConnectNodeId_componentSetWithNilNodeId_fallsBackToOwnNodeId() {
184+
// When containingComponentSet exists but has no nodeId (anomalous API response),
185+
// should fall back to component's own nodeId
186+
let component = makeComponent(
187+
name: "RTL=Off",
188+
componentSetName: "arrow-left",
189+
componentSetNodeId: nil
190+
)
191+
XCTAssertEqual(component.codeConnectNodeId, "1:0")
192+
}
193+
159194
// MARK: - defaultRTLProperty
160195

161196
func testDefaultRTLProperty() {
@@ -168,14 +203,21 @@ final class ComponentRTLTests: XCTestCase {
168203
name: String,
169204
description: String? = nil,
170205
frameName: String = "Icons",
171-
componentSetName: String? = nil
206+
componentSetName: String? = nil,
207+
componentSetNodeId: String? = "99:0"
172208
) -> Component {
173209
let descriptionField = description.map { ", \"description\": \"\($0)\"" } ?? ""
174210

175211
let componentSetField = if let componentSetName {
176-
"""
177-
, "containingComponentSet": { "nodeId": "99:0", "name": "\(componentSetName)" }
178-
"""
212+
if let componentSetNodeId {
213+
"""
214+
, "containingComponentSet": { "nodeId": "\(componentSetNodeId)", "name": "\(componentSetName)" }
215+
"""
216+
} else {
217+
"""
218+
, "containingComponentSet": { "name": "\(componentSetName)" }
219+
"""
220+
}
179221
} else {
180222
""
181223
}

0 commit comments

Comments
 (0)