Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,7 @@ icons = new Android.IconsEntry {
composePackageName = "com.example"
// composeFormat = "resourceReference"
// composeExtensionTarget = "com.example.app.ui.AppIcons"
// codeConnectKotlin = "./main/src/java/com/example/Icons.figma.kt"
// nameStyle = "snake_case"
// pathPrecision = 4
// strictPathValidation = false
Expand All @@ -651,6 +652,7 @@ icons = new Android.IconsEntry {
| `nameStyle` | `NameStyle?` | No | Name style for generated names |
| `pathPrecision` | `Int(1-6)?` | No | Coordinate precision for pathData (default: 4) |
| `strictPathValidation` | `Boolean?` | No | Error on pathData > 32,767 bytes (default: false) |
| `codeConnectKotlin` | `String?` | No | Path to generate Figma Code Connect Kotlin file |

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

Expand All @@ -666,16 +668,19 @@ images = new Android.ImagesEntry {
quality = 90
}
// sourceFormat = "svg"
// codeConnectKotlin = "./main/src/java/com/example/Images.figma.kt"
}
```

| Field | Type | Required | Description |
| -------------- | ------------------ | -------- | -------------------------------------------------------------------- |
| `format` | `ImageFormat` | Yes | Output format: `"svg"`, `"png"`, or `"webp"` |
| `output` | `String` | Yes | Output directory for images (relative to mainRes) |
| `scales` | `Listing<Number>?` | No | Scale factors (valid: 1, 1.5, 2, 3, 4; default: `[1, 1.5, 2, 3, 4]`) |
| `webpOptions` | `WebpOptions?` | No | WebP encoding options (when format is `"webp"`) |
| `sourceFormat` | `SourceFormat?` | No | Source from Figma: `"png"` (default) or `"svg"` |
| Field | Type | Required | Description |
| ------------------- | ------------------ | -------- | -------------------------------------------------------------------- |
| `format` | `ImageFormat` | Yes | Output format: `"svg"`, `"png"`, or `"webp"` |
| `output` | `String` | Yes | Output directory for images (relative to mainRes) |
| `scales` | `Listing<Number>?` | No | Scale factors (valid: 1, 1.5, 2, 3, 4; default: `[1, 1.5, 2, 3, 4]`) |
| `webpOptions` | `WebpOptions?` | No | WebP encoding options (when format is `"webp"`) |
| `sourceFormat` | `SourceFormat?` | No | Source from Figma: `"png"` (default) or `"svg"` |
| `nameStyle` | `NameStyle?` | No | Name style for generated names |
| `codeConnectKotlin` | `String?` | No | Path to generate Figma Code Connect Kotlin file |

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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Flutter, and React/TypeScript.
### Platform Support

- 📱 SwiftUI and UIKit (iOS/macOS)
- 🔗 Figma Code Connect integration (iOS)
- 🔗 Figma Code Connect integration (iOS, Android)
- 🤖 Jetpack Compose and XML resources (Android)
- ⚠️ Android pathData validation (errors on 32,767 bytes AAPT limit)
- 🦋 Flutter / Dart
Expand Down
92 changes: 92 additions & 0 deletions Sources/AndroidExport/AndroidCodeConnectExporter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import ExFigCore
import Foundation
import Stencil

/// Generates Figma Code Connect Kotlin files for Jetpack Compose.
///
/// Code Connect files link Figma design components to Compose code,
/// enabling designers to see the corresponding Compose implementation
/// in Figma Dev Mode.
public final class AndroidCodeConnectExporter: AndroidExporter {
override public init(templatesPath: URL? = nil) {
super.init(templatesPath: templatesPath)
}

/// Generates a Code Connect Kotlin file from asset packs (icons or images).
///
/// - Parameters:
/// - imagePacks: Asset packs with nodeId and fileId for Code Connect URLs.
/// - url: Output URL for the generated `.figma.kt` file.
/// - packageName: Kotlin package name for the generated file.
/// - xmlResourcePackage: Package for the `R` class import.
/// - allAssetMetadata: Optional full asset metadata for granular cache mode.
/// When provided, generates Code Connect for ALL assets (not just changed ones).
/// - Returns: File contents to write, or nil if no valid assets with nodeId.
public func generateCodeConnect(
imagePacks: [AssetPair<ImagePack>],
url: URL,
packageName: String,
xmlResourcePackage: String,
allAssetMetadata: [AssetMetadata]? = nil
) throws -> FileContents? {
let assets: [[String: String]]

if let allMetadata = allAssetMetadata, !allMetadata.isEmpty {
let validMetadata = allMetadata.filter { !$0.nodeId.isEmpty && !$0.fileId.isEmpty }
guard !validMetadata.isEmpty else { return nil }
assets = validMetadata.map { meta in
makeAssetContext(name: meta.name, nodeId: meta.nodeId, fileId: meta.fileId)
}
} else {
assets = imagePacks.compactMap { pack -> [String: String]? in
guard let nodeId = pack.light.nodeId, let fileId = pack.light.fileId else { return nil }
return makeAssetContext(name: pack.light.name, nodeId: nodeId, fileId: fileId)
}
guard !assets.isEmpty else { return nil }
}

let sortedAssets = assets.sorted { ($0["name"] ?? "") < ($1["name"] ?? "") }

let context: [String: Any] = [
"package": packageName,
"xmlResourcePackage": xmlResourcePackage,
"assets": sortedAssets,
]

let env = makeEnvironment()
let contents = try env.renderTemplate(name: "CodeConnect.figma.kt.stencil", context: context)

let directory = url.deletingLastPathComponent()
let file = URL(fileURLWithPath: url.lastPathComponent)
return try makeFileContents(for: contents, directory: directory, file: file)
}

// MARK: - Private

/// Builds a template context dictionary for a single asset.
///
/// - `resourceName`: sanitized for `R.drawable.*` (non-alphanumeric → `_`, no leading digit).
/// - `className`: unique Composable function name (`Asset_<sanitized>`).
private func makeAssetContext(name: String, nodeId: String, fileId: String) -> [String: String] {
let urlNodeId = nodeId.replacingOccurrences(of: ":", with: "-")
let sanitizedName = name.map { $0.isLetter || $0.isNumber ? $0 : Character("_") }
let className = "Asset_\(String(sanitizedName))"

// Sanitize for R.drawable: only [a-z0-9_], no leading digit
var resourceName = name.map { $0.isLetter || $0.isNumber || $0 == Character("_") ? $0 : Character("_") }
if let first = resourceName.first, first.isNumber {
resourceName.insert(Character("_"), at: resourceName.startIndex)
}

let figmaUrl = "https://www.figma.com/design/\(fileId)?node-id=\(urlNodeId)"

return [
"name": name,
"resourceName": String(resourceName),
"className": className,
"nodeId": urlNodeId,
"fileId": fileId,
"figmaUrl": figmaUrl,
]
}
}
7 changes: 4 additions & 3 deletions Sources/AndroidExport/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,28 @@ Orchestration (Figma fetching, processing, file writing) lives in `ExFig-Android

### Dual Output System

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

| Exporter | XML Output | Compose Output |
| ------------------------------ | ----------------------------------------------- | -------------------------------- |
| AndroidColorExporter | `values/colors.xml` + `values-night/colors.xml` | `Colors.kt` |
| AndroidTypographyExporter | `typography.xml` | `Typography.kt` |
| AndroidComposeIconExporter | (none) | `Icons.kt` |
| AndroidCodeConnectExporter | (none) | `*.figma.kt` (Code Connect) |
| AndroidImageVectorExporter | (none) | `IconName.kt` (ImageVector code) |
| AndroidThemeAttributesExporter | `attrs.xml` + `styles.xml` content | (none) |

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

### Class Hierarchy

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

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

### Template System

Six Stencil templates in `Resources/`: `colors.xml.stencil`, `Colors.kt.stencil`, `typography.xml.stencil`, `Typography.kt.stencil`, `Icons.kt.stencil`, `header.stencil`.
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`.

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

Expand Down
22 changes: 22 additions & 0 deletions Sources/AndroidExport/Resources/CodeConnect.figma.kt.stencil
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
{% include "header.stencil" %}
*/
package {{ package }}

import androidx.compose.material.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import com.figma.code.connect.FigmaConnect
import {{ xmlResourcePackage }}.R

{% for asset in assets %}
@FigmaConnect(url = "{{ asset.figmaUrl }}")
@Composable
fun {{ asset.className }}() {
Icon(
painter = painterResource(id = R.drawable.{{ asset.resourceName }}),
contentDescription = null
)
}

{% endfor %}
4 changes: 4 additions & 0 deletions Sources/ExFig-Android/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ entry.resolvedMainSrc(fallback: platformConfig.mainSrc) // Kotlin src/ (col

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

Both modes optionally generate Figma Code Connect (`.figma.kt`) when `codeConnectKotlin` is set. Requires `composePackageName` + `resourcePackage`.

### Images: Format Matrix

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

PNG→SVG is unsupported and throws `incompatibleFormat`.

All 5 pipelines optionally generate Figma Code Connect (`.figma.kt`) when `codeConnectKotlin` is set. Uses `resourcePackage` as both package name and R class package.

SVG images always use `scales: [1.0]` and `sourceFormat: .svg` — the `ImagesSourceInput` is constructed inline in `loadAndProcessSVG()`, NOT via `entry.imagesSourceInput()`.

### Density Folder Mapping
Expand Down
5 changes: 5 additions & 0 deletions Sources/ExFig-Android/Config/AndroidIconsEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ public extension Android.IconsEntry {
return nameStyle.coreNameStyle
}

/// URL for Code Connect Kotlin file output.
var codeConnectKotlinURL: URL? {
codeConnectKotlin.map { URL(fileURLWithPath: $0) }
}

/// Effective compose format, defaulting to resourceReference.
var effectiveComposeFormat: Android.ComposeIconFormat {
composeFormat ?? .resourceReference
Expand Down
5 changes: 5 additions & 0 deletions Sources/ExFig-Android/Config/AndroidImagesEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ public extension Android.ImagesEntry {
)
}

/// URL for Code Connect Kotlin file output.
var codeConnectKotlinURL: URL? {
codeConnectKotlin.map { URL(fileURLWithPath: $0) }
}

/// Effective source format, defaulting to PNG.
var effectiveSourceFormat: ImageSourceFormat {
guard let sourceFormat else { return .png }
Expand Down
47 changes: 46 additions & 1 deletion Sources/ExFig-Android/Export/AndroidIconsExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ private extension AndroidIconsExporter {
allFiles.append(composeFile)
}

// Generate Code Connect if configured
if let codeConnectFile = try generateCodeConnect(
iconPairs: iconPairs, entry: entry, platformConfig: platformConfig, context: context
) {
allFiles.append(codeConnectFile)
}

let filesToWrite = allFiles
try await context.withSpinner("Writing files to Android project...") {
try context.writeFiles(filesToWrite)
Expand Down Expand Up @@ -157,10 +164,37 @@ private extension AndroidIconsExporter {
allIconNames: nil
)
}

func generateCodeConnect(
iconPairs: [AssetPair<ImagePack>],
entry: AndroidIconsEntry,
platformConfig: AndroidPlatformConfig,
context: some IconsExportContext
) throws -> FileContents? {
guard let url = entry.codeConnectKotlinURL else { return nil }
guard let packageName = entry.composePackageName else {
context.warning("Code Connect skipped: 'composePackageName' is required")
return nil
}
guard let resourcePackage = platformConfig.resourcePackage else {
context.warning("Code Connect skipped: 'resourcePackage' is required")
return nil
}
let exporter = AndroidCodeConnectExporter(
templatesPath: entry.resolvedTemplatesPath(fallback: platformConfig.templatesPath)
)
return try exporter.generateCodeConnect(
imagePacks: iconPairs,
url: url,
packageName: packageName,
xmlResourcePackage: resourcePackage
)
}
}

// MARK: - ImageVector Export

// swiftlint:disable function_body_length
private extension AndroidIconsExporter {
func exportAsImageVector(
entry: AndroidIconsEntry,
Expand Down Expand Up @@ -229,8 +263,17 @@ private extension AndroidIconsExporter {
return try await exporter.exportAsync(svgFiles: svgFiles)
}

// Generate Code Connect if configured
var allKotlinFiles = kotlinFiles
if let codeConnectFile = try generateCodeConnect(
iconPairs: iconPairs, entry: entry, platformConfig: platformConfig, context: context
) {
allKotlinFiles.append(codeConnectFile)
}

let filesToWrite = allKotlinFiles
try await context.withSpinner("Writing Kotlin files to Android project...") {
try context.writeFiles(kotlinFiles)
try context.writeFiles(filesToWrite)
}

// Cleanup
Expand All @@ -241,6 +284,8 @@ private extension AndroidIconsExporter {
}
}

// swiftlint:enable function_body_length

// MARK: - Load & Process

private extension AndroidIconsExporter {
Expand Down
Loading
Loading