Skip to content

Commit b84d3a3

Browse files
committed
feat: add Figma Code Connect generation for Android
Introduce functionality to generate Figma Code Connect Kotlin files for Jetpack Compose. The `AndroidCodeConnectExporter` class and related methods allow linking Figma design components to Compose code, creating an important connection for designers to view Compose implementations in Figma Dev Mode. - Added `AndroidCodeConnectExporter` with a method to generate Kotlin files from image packs. - Expanded `AndroidIconsEntry` and `AndroidImagesEntry` to include a `codeConnectKotlin` field for file path configuration. - Updated configuration schemas and generator logic for Code Connect inclusion. - Integrated Code Connect generation into icon and image export workflows within the `AndroidIconsExporter` and `AndroidImagesExporter` classes. - Created tests for `AndroidCodeConnectExporter` to ensure expected behavior, including asset filtering, URL generation, and file output.
1 parent 0a2b421 commit b84d3a3

16 files changed

Lines changed: 635 additions & 202 deletions

File tree

CONFIG.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,7 @@ icons = new Android.IconsEntry {
636636
composePackageName = "com.example"
637637
// composeFormat = "resourceReference"
638638
// composeExtensionTarget = "com.example.app.ui.AppIcons"
639+
// codeConnectKotlin = "./main/src/java/com/example/Icons.figma.kt"
639640
// nameStyle = "snake_case"
640641
// pathPrecision = 4
641642
// strictPathValidation = false
@@ -651,6 +652,7 @@ icons = new Android.IconsEntry {
651652
| `nameStyle` | `NameStyle?` | No | Name style for generated names |
652653
| `pathPrecision` | `Int(1-6)?` | No | Coordinate precision for pathData (default: 4) |
653654
| `strictPathValidation` | `Boolean?` | No | Error on pathData > 32,767 bytes (default: false) |
655+
| `codeConnectKotlin` | `String?` | No | Path to generate Figma Code Connect Kotlin file |
654656

655657
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
656658

@@ -666,16 +668,18 @@ images = new Android.ImagesEntry {
666668
quality = 90
667669
}
668670
// sourceFormat = "svg"
671+
// codeConnectKotlin = "./main/src/java/com/example/Images.figma.kt"
669672
}
670673
```
671674

672-
| Field | Type | Required | Description |
673-
| -------------- | ------------------ | -------- | -------------------------------------------------------------------- |
674-
| `format` | `ImageFormat` | Yes | Output format: `"svg"`, `"png"`, or `"webp"` |
675-
| `output` | `String` | Yes | Output directory for images (relative to mainRes) |
676-
| `scales` | `Listing<Number>?` | No | Scale factors (valid: 1, 1.5, 2, 3, 4; default: `[1, 1.5, 2, 3, 4]`) |
677-
| `webpOptions` | `WebpOptions?` | No | WebP encoding options (when format is `"webp"`) |
678-
| `sourceFormat` | `SourceFormat?` | No | Source from Figma: `"png"` (default) or `"svg"` |
675+
| Field | Type | Required | Description |
676+
| ------------------- | ------------------ | -------- | -------------------------------------------------------------------- |
677+
| `format` | `ImageFormat` | Yes | Output format: `"svg"`, `"png"`, or `"webp"` |
678+
| `output` | `String` | Yes | Output directory for images (relative to mainRes) |
679+
| `scales` | `Listing<Number>?` | No | Scale factors (valid: 1, 1.5, 2, 3, 4; default: `[1, 1.5, 2, 3, 4]`) |
680+
| `webpOptions` | `WebpOptions?` | No | WebP encoding options (when format is `"webp"`) |
681+
| `sourceFormat` | `SourceFormat?` | No | Source from Figma: `"png"` (default) or `"svg"` |
682+
| `codeConnectKotlin` | `String?` | No | Path to generate Figma Code Connect Kotlin file |
679683

680684
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
681685

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Flutter, and React/TypeScript.
3636
### Platform Support
3737

3838
- 📱 SwiftUI and UIKit (iOS/macOS)
39-
- 🔗 Figma Code Connect integration (iOS)
39+
- 🔗 Figma Code Connect integration (iOS, Android)
4040
- 🤖 Jetpack Compose and XML resources (Android)
4141
- ⚠️ Android pathData validation (errors on 32,767 bytes AAPT limit)
4242
- 🦋 Flutter / Dart
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import ExFigCore
2+
import Foundation
3+
import Stencil
4+
5+
/// Generates Figma Code Connect Kotlin files for Jetpack Compose.
6+
///
7+
/// Code Connect files link Figma design components to Compose code,
8+
/// enabling designers to see the corresponding Compose implementation
9+
/// in Figma Dev Mode.
10+
public final class AndroidCodeConnectExporter: AndroidExporter {
11+
override public init(templatesPath: URL? = nil) {
12+
super.init(templatesPath: templatesPath)
13+
}
14+
15+
/// Generates a Code Connect Kotlin file from image packs.
16+
///
17+
/// - Parameters:
18+
/// - imagePacks: Image packs with nodeId and fileId for Code Connect URLs.
19+
/// - url: Output URL for the generated `.figma.kt` file.
20+
/// - packageName: Kotlin package name for the generated file.
21+
/// - xmlResourcePackage: Package for the `R` class import.
22+
/// - allAssetMetadata: Optional full asset metadata for granular cache mode.
23+
/// When provided, generates Code Connect for ALL assets (not just changed ones).
24+
/// - Returns: File contents to write, or nil if no valid assets with nodeId.
25+
public func generateCodeConnect(
26+
imagePacks: [AssetPair<ImagePack>],
27+
url: URL,
28+
packageName: String,
29+
xmlResourcePackage: String,
30+
allAssetMetadata: [AssetMetadata]? = nil
31+
) throws -> FileContents? {
32+
let assets: [[String: String]]
33+
34+
if let allMetadata = allAssetMetadata, !allMetadata.isEmpty {
35+
assets = allMetadata.map { meta in
36+
makeAssetContext(name: meta.name, nodeId: meta.nodeId, fileId: meta.fileId)
37+
}
38+
} else {
39+
let validAssets = imagePacks.filter { pack in
40+
pack.light.nodeId != nil && pack.light.fileId != nil
41+
}
42+
guard !validAssets.isEmpty else { return nil }
43+
44+
assets = validAssets.map { pack in
45+
makeAssetContext(
46+
name: pack.light.name,
47+
nodeId: pack.light.nodeId ?? "",
48+
fileId: pack.light.fileId ?? ""
49+
)
50+
}
51+
}
52+
53+
guard !assets.isEmpty else { return nil }
54+
55+
let sortedAssets = assets.sorted { ($0["name"] ?? "") < ($1["name"] ?? "") }
56+
57+
let context: [String: Any] = [
58+
"package": packageName,
59+
"xmlResourcePackage": xmlResourcePackage,
60+
"assets": sortedAssets,
61+
]
62+
63+
let env = makeEnvironment()
64+
let contents = try env.renderTemplate(name: "CodeConnect.figma.kt.stencil", context: context)
65+
66+
let directory = url.deletingLastPathComponent()
67+
let file = URL(fileURLWithPath: url.lastPathComponent)
68+
return try makeFileContents(for: contents, directory: directory, file: file)
69+
}
70+
71+
// MARK: - Private
72+
73+
private func makeAssetContext(name: String, nodeId: String, fileId: String) -> [String: String] {
74+
let urlNodeId = nodeId.replacingOccurrences(of: ":", with: "-")
75+
let sanitizedName = name.map { $0.isLetter || $0.isNumber ? $0 : Character("_") }
76+
let className = "Asset_\(String(sanitizedName))"
77+
let figmaUrl = "https://www.figma.com/design/\(fileId)?node-id=\(urlNodeId)"
78+
79+
return [
80+
"name": name,
81+
"className": className,
82+
"nodeId": urlNodeId,
83+
"fileId": fileId,
84+
"figmaUrl": figmaUrl,
85+
]
86+
}
87+
}

Sources/AndroidExport/CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,21 @@ Every exporter produces BOTH XML resources AND Kotlin Compose code:
2727
| AndroidColorExporter | `values/colors.xml` + `values-night/colors.xml` | `Colors.kt` |
2828
| AndroidTypographyExporter | `typography.xml` | `Typography.kt` |
2929
| AndroidComposeIconExporter | (none) | `Icons.kt` |
30+
| AndroidCodeConnectExporter | (none) | `*.figma.kt` (Code Connect) |
3031
| AndroidImageVectorExporter | (none) | `IconName.kt` (ImageVector code) |
3132
| AndroidThemeAttributesExporter | `attrs.xml` + `styles.xml` content | (none) |
3233

3334
XML generation can be disabled per-entry via `AndroidOutput.xmlDisabled`.
3435

3536
### Class Hierarchy
3637

37-
`AndroidExporter` is the base class providing Stencil template loading and `FileContents` creation. `AndroidColorExporter`, `AndroidTypographyExporter`, `AndroidComposeIconExporter` inherit from it.
38+
`AndroidExporter` is the base class providing Stencil template loading and `FileContents` creation. `AndroidColorExporter`, `AndroidTypographyExporter`, `AndroidComposeIconExporter`, and `AndroidCodeConnectExporter` inherit from it.
3839

3940
`AndroidImageVectorExporter` and `AndroidThemeAttributesExporter` are standalone (`Sendable`) — they don't use Stencil templates.
4041

4142
### Template System
4243

43-
Six Stencil templates in `Resources/`: `colors.xml.stencil`, `Colors.kt.stencil`, `typography.xml.stencil`, `Typography.kt.stencil`, `Icons.kt.stencil`, `header.stencil`.
44+
Seven Stencil templates in `Resources/`: `colors.xml.stencil`, `Colors.kt.stencil`, `typography.xml.stencil`, `Typography.kt.stencil`, `Icons.kt.stencil`, `CodeConnect.figma.kt.stencil`, `header.stencil`.
4445

4546
Template loading priority: custom `templatesPath` (from PKL config) > `Bundle.module` resources. StencilSwiftKit extensions are registered for all environments.
4647

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
{% include "header.stencil" %}
3+
*/
4+
package {{ package }}
5+
6+
import androidx.compose.runtime.Composable
7+
import androidx.compose.ui.res.painterResource
8+
import com.figma.code.connect.FigmaConnect
9+
import {{ xmlResourcePackage }}.R
10+
11+
{% for asset in assets %}
12+
@FigmaConnect(url = "{{ asset.figmaUrl }}")
13+
@Composable
14+
fun {{ asset.className }}() {
15+
androidx.compose.material.Icon(
16+
painter = painterResource(id = R.drawable.{{ asset.name }}),
17+
contentDescription = null
18+
)
19+
}
20+
21+
{% endfor %}

Sources/ExFig-Android/CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ entry.resolvedMainSrc(fallback: platformConfig.mainSrc) // Kotlin src/ (col
5959

6060
Both modes use temp directories for SVG download, then convert and write to final output.
6161

62+
Both modes optionally generate Figma Code Connect (`.figma.kt`) when `codeConnectKotlin` is set. Requires `composePackageName` + `resourcePackage`.
63+
6264
### Images: Format Matrix
6365

6466
`AndroidImagesExporter` handles 5 source→output combinations:
@@ -73,6 +75,8 @@ Both modes use temp directories for SVG download, then convert and write to fina
7375

7476
PNG→SVG is unsupported and throws `incompatibleFormat`.
7577

78+
All 5 pipelines optionally generate Figma Code Connect (`.figma.kt`) when `codeConnectKotlin` is set. Uses `resourcePackage` as both package name and R class package.
79+
7680
SVG images always use `scales: [1.0]` and `sourceFormat: .svg` — the `ImagesSourceInput` is constructed inline in `loadAndProcessSVG()`, NOT via `entry.imagesSourceInput()`.
7781

7882
### Density Folder Mapping

Sources/ExFig-Android/Config/AndroidIconsEntry.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ public extension Android.IconsEntry {
3333
return nameStyle.coreNameStyle
3434
}
3535

36+
/// URL for Code Connect Kotlin file output.
37+
var codeConnectKotlinURL: URL? {
38+
codeConnectKotlin.map { URL(fileURLWithPath: $0) }
39+
}
40+
3641
/// Effective compose format, defaulting to resourceReference.
3742
var effectiveComposeFormat: Android.ComposeIconFormat {
3843
composeFormat ?? .resourceReference

Sources/ExFig-Android/Config/AndroidImagesEntry.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ public extension Android.ImagesEntry {
4545
)
4646
}
4747

48+
/// URL for Code Connect Kotlin file output.
49+
var codeConnectKotlinURL: URL? {
50+
codeConnectKotlin.map { URL(fileURLWithPath: $0) }
51+
}
52+
4853
/// Effective source format, defaulting to PNG.
4954
var effectiveSourceFormat: ImageSourceFormat {
5055
guard let sourceFormat else { return .png }

Sources/ExFig-Android/Export/AndroidIconsExporter.swift

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,13 @@ private extension AndroidIconsExporter {
122122
allFiles.append(composeFile)
123123
}
124124

125+
// Generate Code Connect if configured
126+
if let codeConnectFile = try generateCodeConnect(
127+
iconPairs: iconPairs, entry: entry, platformConfig: platformConfig
128+
) {
129+
allFiles.append(codeConnectFile)
130+
}
131+
125132
let filesToWrite = allFiles
126133
try await context.withSpinner("Writing files to Android project...") {
127134
try context.writeFiles(filesToWrite)
@@ -157,10 +164,33 @@ private extension AndroidIconsExporter {
157164
allIconNames: nil
158165
)
159166
}
167+
168+
func generateCodeConnect(
169+
iconPairs: [AssetPair<ImagePack>],
170+
entry: AndroidIconsEntry,
171+
platformConfig: AndroidPlatformConfig
172+
) throws -> FileContents? {
173+
guard let url = entry.codeConnectKotlinURL,
174+
let packageName = entry.composePackageName,
175+
let resourcePackage = platformConfig.resourcePackage
176+
else {
177+
return nil
178+
}
179+
let exporter = AndroidCodeConnectExporter(
180+
templatesPath: entry.resolvedTemplatesPath(fallback: platformConfig.templatesPath)
181+
)
182+
return try exporter.generateCodeConnect(
183+
imagePacks: iconPairs,
184+
url: url,
185+
packageName: packageName,
186+
xmlResourcePackage: resourcePackage
187+
)
188+
}
160189
}
161190

162191
// MARK: - ImageVector Export
163192

193+
// swiftlint:disable function_body_length
164194
private extension AndroidIconsExporter {
165195
func exportAsImageVector(
166196
entry: AndroidIconsEntry,
@@ -229,8 +259,17 @@ private extension AndroidIconsExporter {
229259
return try await exporter.exportAsync(svgFiles: svgFiles)
230260
}
231261

262+
// Generate Code Connect if configured
263+
var allKotlinFiles = kotlinFiles
264+
if let codeConnectFile = try generateCodeConnect(
265+
iconPairs: iconPairs, entry: entry, platformConfig: platformConfig
266+
) {
267+
allKotlinFiles.append(codeConnectFile)
268+
}
269+
270+
let filesToWrite = allKotlinFiles
232271
try await context.withSpinner("Writing Kotlin files to Android project...") {
233-
try context.writeFiles(kotlinFiles)
272+
try context.writeFiles(filesToWrite)
234273
}
235274

236275
// Cleanup
@@ -241,6 +280,8 @@ private extension AndroidIconsExporter {
241280
}
242281
}
243282

283+
// swiftlint:enable function_body_length
284+
244285
// MARK: - Load & Process
245286

246287
private extension AndroidIconsExporter {

0 commit comments

Comments
 (0)