Skip to content

Commit 9a45993

Browse files
authored
Add Figma Code Connect generation for Android (Jetpack Compose) (#58)
* 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. * fix: after review
1 parent 0a2b421 commit 9a45993

19 files changed

Lines changed: 725 additions & 206 deletions

File tree

CONFIG.md

Lines changed: 12 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,19 @@ 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+
| `nameStyle` | `NameStyle?` | No | Name style for generated names |
683+
| `codeConnectKotlin` | `String?` | No | Path to generate Figma Code Connect Kotlin file |
679684

680685
**Inherited from `FrameSource`:** `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`.
681686

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: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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 asset packs (icons or images).
16+
///
17+
/// - Parameters:
18+
/// - imagePacks: Asset 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+
let validMetadata = allMetadata.filter { !$0.nodeId.isEmpty && !$0.fileId.isEmpty }
36+
guard !validMetadata.isEmpty else { return nil }
37+
assets = validMetadata.map { meta in
38+
makeAssetContext(name: meta.name, nodeId: meta.nodeId, fileId: meta.fileId)
39+
}
40+
} else {
41+
assets = imagePacks.compactMap { pack -> [String: String]? in
42+
guard let nodeId = pack.light.nodeId, let fileId = pack.light.fileId else { return nil }
43+
return makeAssetContext(name: pack.light.name, nodeId: nodeId, fileId: fileId)
44+
}
45+
guard !assets.isEmpty else { return nil }
46+
}
47+
48+
let sortedAssets = assets.sorted { ($0["name"] ?? "") < ($1["name"] ?? "") }
49+
50+
let context: [String: Any] = [
51+
"package": packageName,
52+
"xmlResourcePackage": xmlResourcePackage,
53+
"assets": sortedAssets,
54+
]
55+
56+
let env = makeEnvironment()
57+
let contents = try env.renderTemplate(name: "CodeConnect.figma.kt.stencil", context: context)
58+
59+
let directory = url.deletingLastPathComponent()
60+
let file = URL(fileURLWithPath: url.lastPathComponent)
61+
return try makeFileContents(for: contents, directory: directory, file: file)
62+
}
63+
64+
// MARK: - Private
65+
66+
/// Builds a template context dictionary for a single asset.
67+
///
68+
/// - `resourceName`: sanitized for `R.drawable.*` (non-alphanumeric → `_`, no leading digit).
69+
/// - `className`: unique Composable function name (`Asset_<sanitized>`).
70+
private func makeAssetContext(name: String, nodeId: String, fileId: String) -> [String: String] {
71+
let urlNodeId = nodeId.replacingOccurrences(of: ":", with: "-")
72+
let sanitizedName = name.map { $0.isLetter || $0.isNumber ? $0 : Character("_") }
73+
let className = "Asset_\(String(sanitizedName))"
74+
75+
// Sanitize for R.drawable: only [a-z0-9_], no leading digit
76+
var resourceName = name.map { $0.isLetter || $0.isNumber || $0 == Character("_") ? $0 : Character("_") }
77+
if let first = resourceName.first, first.isNumber {
78+
resourceName.insert(Character("_"), at: resourceName.startIndex)
79+
}
80+
81+
let figmaUrl = "https://www.figma.com/design/\(fileId)?node-id=\(urlNodeId)"
82+
83+
return [
84+
"name": name,
85+
"resourceName": String(resourceName),
86+
"className": className,
87+
"nodeId": urlNodeId,
88+
"fileId": fileId,
89+
"figmaUrl": figmaUrl,
90+
]
91+
}
92+
}

Sources/AndroidExport/CLAUDE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,27 +20,28 @@ Orchestration (Figma fetching, processing, file writing) lives in `ExFig-Android
2020

2121
### Dual Output System
2222

23-
Every exporter produces BOTH XML resources AND Kotlin Compose code:
23+
Exporters produce XML resources, Kotlin Compose code, or both:
2424

2525
| Exporter | XML Output | Compose Output |
2626
| ------------------------------ | ----------------------------------------------- | -------------------------------- |
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: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
{% include "header.stencil" %}
3+
*/
4+
package {{ package }}
5+
6+
import androidx.compose.material.Icon
7+
import androidx.compose.runtime.Composable
8+
import androidx.compose.ui.res.painterResource
9+
import com.figma.code.connect.FigmaConnect
10+
import {{ xmlResourcePackage }}.R
11+
12+
{% for asset in assets %}
13+
@FigmaConnect(url = "{{ asset.figmaUrl }}")
14+
@Composable
15+
fun {{ asset.className }}() {
16+
Icon(
17+
painter = painterResource(id = R.drawable.{{ asset.resourceName }}),
18+
contentDescription = null
19+
)
20+
}
21+
22+
{% 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: 46 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, context: context
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,37 @@ private extension AndroidIconsExporter {
157164
allIconNames: nil
158165
)
159166
}
167+
168+
func generateCodeConnect(
169+
iconPairs: [AssetPair<ImagePack>],
170+
entry: AndroidIconsEntry,
171+
platformConfig: AndroidPlatformConfig,
172+
context: some IconsExportContext
173+
) throws -> FileContents? {
174+
guard let url = entry.codeConnectKotlinURL else { return nil }
175+
guard let packageName = entry.composePackageName else {
176+
context.warning("Code Connect skipped: 'composePackageName' is required")
177+
return nil
178+
}
179+
guard let resourcePackage = platformConfig.resourcePackage else {
180+
context.warning("Code Connect skipped: 'resourcePackage' is required")
181+
return nil
182+
}
183+
let exporter = AndroidCodeConnectExporter(
184+
templatesPath: entry.resolvedTemplatesPath(fallback: platformConfig.templatesPath)
185+
)
186+
return try exporter.generateCodeConnect(
187+
imagePacks: iconPairs,
188+
url: url,
189+
packageName: packageName,
190+
xmlResourcePackage: resourcePackage
191+
)
192+
}
160193
}
161194

162195
// MARK: - ImageVector Export
163196

197+
// swiftlint:disable function_body_length
164198
private extension AndroidIconsExporter {
165199
func exportAsImageVector(
166200
entry: AndroidIconsEntry,
@@ -229,8 +263,17 @@ private extension AndroidIconsExporter {
229263
return try await exporter.exportAsync(svgFiles: svgFiles)
230264
}
231265

266+
// Generate Code Connect if configured
267+
var allKotlinFiles = kotlinFiles
268+
if let codeConnectFile = try generateCodeConnect(
269+
iconPairs: iconPairs, entry: entry, platformConfig: platformConfig, context: context
270+
) {
271+
allKotlinFiles.append(codeConnectFile)
272+
}
273+
274+
let filesToWrite = allKotlinFiles
232275
try await context.withSpinner("Writing Kotlin files to Android project...") {
233-
try context.writeFiles(kotlinFiles)
276+
try context.writeFiles(filesToWrite)
234277
}
235278

236279
// Cleanup
@@ -241,6 +284,8 @@ private extension AndroidIconsExporter {
241284
}
242285
}
243286

287+
// swiftlint:enable function_body_length
288+
244289
// MARK: - Load & Process
245290

246291
private extension AndroidIconsExporter {

0 commit comments

Comments
 (0)