From b84d3a3bf63c2927165c307d0d5651cbcb8badfb Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 18 Feb 2026 00:25:18 +0500 Subject: [PATCH 1/2] 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. --- CONFIG.md | 18 +- README.md | 2 +- .../AndroidCodeConnectExporter.swift | 87 +++++++ Sources/AndroidExport/CLAUDE.md | 5 +- .../Resources/CodeConnect.figma.kt.stencil | 21 ++ Sources/ExFig-Android/CLAUDE.md | 4 + .../Config/AndroidIconsEntry.swift | 5 + .../Config/AndroidImagesEntry.swift | 5 + .../Export/AndroidIconsExporter.swift | 43 +++- .../Export/AndroidImagesExporter.swift | 71 ++++- .../ExFigCLI/Resources/Schemas/Android.pkl | 6 + .../ExFigCLI/Resources/androidConfig.swift | 4 + .../ExFigConfig/Generated/Android.pkl.swift | 242 +++++++++--------- .../ExFigConfig/Generated/Common.pkl.swift | 138 +++++----- .../AndroidCodeConnectExporterTests.swift | 182 +++++++++++++ .../ExFigTests/Input/EnumBridgingTests.swift | 4 + 16 files changed, 635 insertions(+), 202 deletions(-) create mode 100644 Sources/AndroidExport/AndroidCodeConnectExporter.swift create mode 100644 Sources/AndroidExport/Resources/CodeConnect.figma.kt.stencil create mode 100644 Tests/AndroidExportTests/AndroidCodeConnectExporterTests.swift diff --git a/CONFIG.md b/CONFIG.md index 1a06eb30..bd8f0f37 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -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 @@ -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`. @@ -666,16 +668,18 @@ 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?` | 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?` | 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"` | +| `codeConnectKotlin` | `String?` | No | Path to generate Figma Code Connect Kotlin file | **Inherited from `FrameSource`:** `figmaFrameName`, `figmaPageName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`. diff --git a/README.md b/README.md index c4d2a39d..a96a98b5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/AndroidExport/AndroidCodeConnectExporter.swift b/Sources/AndroidExport/AndroidCodeConnectExporter.swift new file mode 100644 index 00000000..bd7f3bda --- /dev/null +++ b/Sources/AndroidExport/AndroidCodeConnectExporter.swift @@ -0,0 +1,87 @@ +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 image packs. + /// + /// - Parameters: + /// - imagePacks: Image 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], + url: URL, + packageName: String, + xmlResourcePackage: String, + allAssetMetadata: [AssetMetadata]? = nil + ) throws -> FileContents? { + let assets: [[String: String]] + + if let allMetadata = allAssetMetadata, !allMetadata.isEmpty { + assets = allMetadata.map { meta in + makeAssetContext(name: meta.name, nodeId: meta.nodeId, fileId: meta.fileId) + } + } else { + let validAssets = imagePacks.filter { pack in + pack.light.nodeId != nil && pack.light.fileId != nil + } + guard !validAssets.isEmpty else { return nil } + + assets = validAssets.map { pack in + makeAssetContext( + name: pack.light.name, + nodeId: pack.light.nodeId ?? "", + fileId: pack.light.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 + + 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))" + let figmaUrl = "https://www.figma.com/design/\(fileId)?node-id=\(urlNodeId)" + + return [ + "name": name, + "className": className, + "nodeId": urlNodeId, + "fileId": fileId, + "figmaUrl": figmaUrl, + ] + } +} diff --git a/Sources/AndroidExport/CLAUDE.md b/Sources/AndroidExport/CLAUDE.md index 808df8b4..3174098d 100644 --- a/Sources/AndroidExport/CLAUDE.md +++ b/Sources/AndroidExport/CLAUDE.md @@ -27,6 +27,7 @@ Every exporter produces BOTH XML resources AND Kotlin Compose code: | 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) | @@ -34,13 +35,13 @@ 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. diff --git a/Sources/AndroidExport/Resources/CodeConnect.figma.kt.stencil b/Sources/AndroidExport/Resources/CodeConnect.figma.kt.stencil new file mode 100644 index 00000000..ab53c9ac --- /dev/null +++ b/Sources/AndroidExport/Resources/CodeConnect.figma.kt.stencil @@ -0,0 +1,21 @@ +/* +{% include "header.stencil" %} +*/ +package {{ package }} + +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 }}() { + androidx.compose.material.Icon( + painter = painterResource(id = R.drawable.{{ asset.name }}), + contentDescription = null + ) +} + +{% endfor %} \ No newline at end of file diff --git a/Sources/ExFig-Android/CLAUDE.md b/Sources/ExFig-Android/CLAUDE.md index 31ff9a46..46dc760d 100644 --- a/Sources/ExFig-Android/CLAUDE.md +++ b/Sources/ExFig-Android/CLAUDE.md @@ -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: @@ -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 diff --git a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift index c15c7c64..6929f7cb 100644 --- a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift +++ b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift @@ -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 diff --git a/Sources/ExFig-Android/Config/AndroidImagesEntry.swift b/Sources/ExFig-Android/Config/AndroidImagesEntry.swift index e77ed0a2..4808a801 100644 --- a/Sources/ExFig-Android/Config/AndroidImagesEntry.swift +++ b/Sources/ExFig-Android/Config/AndroidImagesEntry.swift @@ -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 } diff --git a/Sources/ExFig-Android/Export/AndroidIconsExporter.swift b/Sources/ExFig-Android/Export/AndroidIconsExporter.swift index 4700eef3..28cc008b 100644 --- a/Sources/ExFig-Android/Export/AndroidIconsExporter.swift +++ b/Sources/ExFig-Android/Export/AndroidIconsExporter.swift @@ -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 + ) { + allFiles.append(codeConnectFile) + } + let filesToWrite = allFiles try await context.withSpinner("Writing files to Android project...") { try context.writeFiles(filesToWrite) @@ -157,10 +164,33 @@ private extension AndroidIconsExporter { allIconNames: nil ) } + + func generateCodeConnect( + iconPairs: [AssetPair], + entry: AndroidIconsEntry, + platformConfig: AndroidPlatformConfig + ) throws -> FileContents? { + guard let url = entry.codeConnectKotlinURL, + let packageName = entry.composePackageName, + let resourcePackage = platformConfig.resourcePackage + else { + 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, @@ -229,8 +259,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 + ) { + 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 @@ -241,6 +280,8 @@ private extension AndroidIconsExporter { } } +// swiftlint:enable function_body_length + // MARK: - Load & Process private extension AndroidIconsExporter { diff --git a/Sources/ExFig-Android/Export/AndroidImagesExporter.swift b/Sources/ExFig-Android/Export/AndroidImagesExporter.swift index c509cfe6..7e04906e 100644 --- a/Sources/ExFig-Android/Export/AndroidImagesExporter.swift +++ b/Sources/ExFig-Android/Export/AndroidImagesExporter.swift @@ -108,15 +108,22 @@ private extension AndroidImagesExporter { try? FileManager.default.removeItem(atPath: darkDir.path) } - let xmlFiles = localFiles.map { file -> FileContents in + var allFiles = localFiles.map { file -> FileContents in let dir = file.dark ? darkDir : lightDir let source = file.destination.url.deletingPathExtension().appendingPathExtension("xml") let fileURL = file.destination.file.deletingPathExtension().appendingPathExtension("xml") return FileContents(destination: Destination(directory: dir, file: fileURL), dataFile: source) } + if let codeConnectFile = try generateCodeConnect( + imagePairs: imagePairs, entry: entry, platformConfig: platformConfig + ) { + allFiles.append(codeConnectFile) + } + + let filesToWrite = allFiles try await context.withSpinner("Writing files to Android project...") { - try context.writeFiles(xmlFiles) + try context.writeFiles(filesToWrite) } try? FileManager.default.removeItem(at: tempDirs.light) @@ -173,6 +180,12 @@ private extension AndroidImagesExporter { dark: file.dark )) } + if let codeConnectFile = try generateCodeConnect( + imagePairs: imagePairs, entry: entry, platformConfig: platformConfig + ) { + collectedFiles.append(codeConnectFile) + } + let finalFiles = collectedFiles try await context.withSpinner("Writing files to Android project...") { @@ -232,6 +245,13 @@ private extension AndroidImagesExporter { dark: file.dark )) } + + if let codeConnectFile = try generateCodeConnect( + imagePairs: imagePairs, entry: entry, platformConfig: platformConfig + ) { + collectedFiles.append(codeConnectFile) + } + let finalFiles = collectedFiles try await context.withSpinner("Writing files to Android project...") { @@ -274,7 +294,7 @@ private extension AndroidImagesExporter { let scales = entry.effectiveScales let isSingleScale = scales.count == 1 - let finalFiles = localFiles.map { file -> FileContents in + var allFiles = localFiles.map { file -> FileContents in let dirName = Drawable.scaleToDrawableName(file.scale, dark: file.dark, singleScale: isSingleScale) let directory = resolvedMainRes .appendingPathComponent(entry.output) @@ -285,8 +305,15 @@ private extension AndroidImagesExporter { ) } + if let codeConnectFile = try generateCodeConnect( + imagePairs: imagePairs, entry: entry, platformConfig: platformConfig + ) { + allFiles.append(codeConnectFile) + } + + let filesToWrite = allFiles try await context.withSpinner("Writing files to Android project...") { - try context.writeFiles(finalFiles) + try context.writeFiles(filesToWrite) } try? FileManager.default.removeItem(at: tempDir) @@ -317,7 +344,7 @@ private extension AndroidImagesExporter { let scales = entry.effectiveScales let isSingleScale = scales.count == 1 - let finalFiles = localFiles.map { file -> FileContents in + var allFiles = localFiles.map { file -> FileContents in let dirName = Drawable.scaleToDrawableName(file.scale, dark: file.dark, singleScale: isSingleScale) let directory = resolvedMainRes .appendingPathComponent(entry.output) @@ -328,8 +355,15 @@ private extension AndroidImagesExporter { ) } + if let codeConnectFile = try generateCodeConnect( + imagePairs: imagePairs, entry: entry, platformConfig: platformConfig + ) { + allFiles.append(codeConnectFile) + } + + let filesToWrite = allFiles try await context.withSpinner("Writing files to Android project...") { - try context.writeFiles(finalFiles) + try context.writeFiles(filesToWrite) } try? FileManager.default.removeItem(at: tempDir) @@ -338,6 +372,31 @@ private extension AndroidImagesExporter { } } +// MARK: - Code Connect + +private extension AndroidImagesExporter { + func generateCodeConnect( + imagePairs: [AssetPair], + entry: AndroidImagesEntry, + platformConfig: AndroidPlatformConfig + ) throws -> FileContents? { + guard let url = entry.codeConnectKotlinURL, + let resourcePackage = platformConfig.resourcePackage + else { + return nil + } + let exporter = AndroidCodeConnectExporter( + templatesPath: entry.resolvedTemplatesPath(fallback: platformConfig.templatesPath) + ) + return try exporter.generateCodeConnect( + imagePacks: imagePairs, + url: url, + packageName: resourcePackage, + xmlResourcePackage: resourcePackage + ) + } +} + // MARK: - Output Directory private extension AndroidImagesExporter { diff --git a/Sources/ExFigCLI/Resources/Schemas/Android.pkl b/Sources/ExFigCLI/Resources/Schemas/Android.pkl index 19421682..a875ee53 100644 --- a/Sources/ExFigCLI/Resources/Schemas/Android.pkl +++ b/Sources/ExFigCLI/Resources/Schemas/Android.pkl @@ -119,6 +119,9 @@ class IconsEntry extends Common.FrameSource { /// If true, exit with error when pathData exceeds 32,767 bytes. strictPathValidation: Boolean? + + /// Path to generate Figma Code Connect Kotlin file for Jetpack Compose. + codeConnectKotlin: String? } // MARK: - Images @@ -150,6 +153,9 @@ class ImagesEntry extends Common.FrameSource { /// Naming style for generated image names. nameStyle: Common.NameStyle? = "snake_case" + + /// Path to generate Figma Code Connect Kotlin file for Jetpack Compose. + codeConnectKotlin: String? } // MARK: - Typography diff --git a/Sources/ExFigCLI/Resources/androidConfig.swift b/Sources/ExFigCLI/Resources/androidConfig.swift index e274eadb..a249d5fc 100644 --- a/Sources/ExFigCLI/Resources/androidConfig.swift +++ b/Sources/ExFigCLI/Resources/androidConfig.swift @@ -127,6 +127,8 @@ android = new Android.AndroidConfig { // composeFormat = "resourceReference" // [optional] Extension target package for Compose icons // composeExtensionTarget = "androidx.compose.ui.graphics.vector.ImageVector" + // [optional] Path to generate Figma Code Connect Kotlin file for Jetpack Compose + // codeConnectKotlin = "./main/src/java/com/example/Icons.figma.kt" } // Parameters for exporting images images = new Android.ImagesEntry { @@ -136,6 +138,8 @@ android = new Android.AndroidConfig { output = "figma-import-images" // [optional] An array of asset scales that should be downloaded. The valid values are 1 (mdpi), 1.5 (hdpi), 2 (xhdpi), 3 (xxhdpi), 4 (xxxhdpi). The default value is [1, 1.5, 2, 3, 4]. scales = new Listing { 1.0; 2.0; 3.0 } + // [optional] Path to generate Figma Code Connect Kotlin file for Jetpack Compose + // codeConnectKotlin = "./main/src/java/com/example/Images.figma.kt" // Format options for webp format only webpOptions = new Common.WebpOptions { // Encoding type: lossy or lossless diff --git a/Sources/ExFigConfig/Generated/Android.pkl.swift b/Sources/ExFigConfig/Generated/Android.pkl.swift index c8e60781..44efa2c4 100644 --- a/Sources/ExFigConfig/Generated/Android.pkl.swift +++ b/Sources/ExFigConfig/Generated/Android.pkl.swift @@ -19,52 +19,84 @@ extension Android { case webp = "webp" } - /// Root Android platform configuration. - public struct AndroidConfig: PklRegisteredType, Decodable, Hashable, Sendable { - public static let registeredIdentifier: String = "Android#AndroidConfig" + /// Android platform configuration for ExFig. + public struct Module: PklRegisteredType, Decodable, Hashable, Sendable { + public static let registeredIdentifier: String = "Android" - /// Path to main res directory. - public var mainRes: String + public init() {} + } - /// Resource package name (R class package). - public var resourcePackage: String? + /// Name transformation for theme attributes. + public struct NameTransform: PklRegisteredType, Decodable, Hashable, Sendable { + public static let registeredIdentifier: String = "Android#NameTransform" - /// Path to main src directory for Kotlin generation. - public var mainSrc: String? + /// Target case style for attribute names. + public var style: Common.NameStyle? - /// Path to custom Stencil templates. - public var templatesPath: String? + /// Prefix to add to attribute names. + public var prefix: String? - /// Colors configuration entries. - public var colors: [ColorsEntry]? + /// Prefixes to strip from color names before transformation. + public var stripPrefixes: [String]? - /// Icons configuration entries. - public var icons: [IconsEntry]? + public init(style: Common.NameStyle?, prefix: String?, stripPrefixes: [String]?) { + self.style = style + self.prefix = prefix + self.stripPrefixes = stripPrefixes + } + } - /// Images configuration entries. - public var images: [ImagesEntry]? + /// Theme attributes configuration for generating attrs.xml and styles.xml. + public struct ThemeAttributes: PklRegisteredType, Decodable, Hashable, Sendable { + public static let registeredIdentifier: String = "Android#ThemeAttributes" - /// Typography configuration. - public var typography: Typography? + /// Whether theme attributes generation is enabled. + public var enabled: Bool? + + /// Path to attrs.xml relative to mainRes. + public var attrsFile: String? + + /// Path to styles.xml relative to mainRes. + public var stylesFile: String? + + /// Path to styles-night.xml relative to mainRes. + public var stylesNightFile: String? + + /// Theme name used in markers (e.g., "Theme.MyApp.Main"). + public var themeName: String + + /// Custom marker start text. + public var markerStart: String? + + /// Custom marker end text. + public var markerEnd: String? + + /// Name transformation configuration. + public var nameTransform: NameTransform? + + /// If true, create file with markers if missing. + public var autoCreateMarkers: Bool? public init( - mainRes: String, - resourcePackage: String?, - mainSrc: String?, - templatesPath: String?, - colors: [ColorsEntry]?, - icons: [IconsEntry]?, - images: [ImagesEntry]?, - typography: Typography? + enabled: Bool?, + attrsFile: String?, + stylesFile: String?, + stylesNightFile: String?, + themeName: String, + markerStart: String?, + markerEnd: String?, + nameTransform: NameTransform?, + autoCreateMarkers: Bool? ) { - self.mainRes = mainRes - self.resourcePackage = resourcePackage - self.mainSrc = mainSrc - self.templatesPath = templatesPath - self.colors = colors - self.icons = icons - self.images = images - self.typography = typography + self.enabled = enabled + self.attrsFile = attrsFile + self.stylesFile = stylesFile + self.stylesNightFile = stylesNightFile + self.themeName = themeName + self.markerStart = markerStart + self.markerEnd = markerEnd + self.nameTransform = nameTransform + self.autoCreateMarkers = autoCreateMarkers } } @@ -165,87 +197,6 @@ extension Android { } } - /// Theme attributes configuration for generating attrs.xml and styles.xml. - public struct ThemeAttributes: PklRegisteredType, Decodable, Hashable, Sendable { - public static let registeredIdentifier: String = "Android#ThemeAttributes" - - /// Whether theme attributes generation is enabled. - public var enabled: Bool? - - /// Path to attrs.xml relative to mainRes. - public var attrsFile: String? - - /// Path to styles.xml relative to mainRes. - public var stylesFile: String? - - /// Path to styles-night.xml relative to mainRes. - public var stylesNightFile: String? - - /// Theme name used in markers (e.g., "Theme.MyApp.Main"). - public var themeName: String - - /// Custom marker start text. - public var markerStart: String? - - /// Custom marker end text. - public var markerEnd: String? - - /// Name transformation configuration. - public var nameTransform: NameTransform? - - /// If true, create file with markers if missing. - public var autoCreateMarkers: Bool? - - public init( - enabled: Bool?, - attrsFile: String?, - stylesFile: String?, - stylesNightFile: String?, - themeName: String, - markerStart: String?, - markerEnd: String?, - nameTransform: NameTransform?, - autoCreateMarkers: Bool? - ) { - self.enabled = enabled - self.attrsFile = attrsFile - self.stylesFile = stylesFile - self.stylesNightFile = stylesNightFile - self.themeName = themeName - self.markerStart = markerStart - self.markerEnd = markerEnd - self.nameTransform = nameTransform - self.autoCreateMarkers = autoCreateMarkers - } - } - - /// Name transformation for theme attributes. - public struct NameTransform: PklRegisteredType, Decodable, Hashable, Sendable { - public static let registeredIdentifier: String = "Android#NameTransform" - - /// Target case style for attribute names. - public var style: Common.NameStyle? - - /// Prefix to add to attribute names. - public var prefix: String? - - /// Prefixes to strip from color names before transformation. - public var stripPrefixes: [String]? - - public init(style: Common.NameStyle?, prefix: String?, stripPrefixes: [String]?) { - self.style = style - self.prefix = prefix - self.stripPrefixes = stripPrefixes - } - } - - /// Android platform configuration for ExFig. - public struct Module: PklRegisteredType, Decodable, Hashable, Sendable { - public static let registeredIdentifier: String = "Android" - - public init() {} - } - /// Android icons entry configuration. public struct IconsEntry: Common.FrameSource { public static let registeredIdentifier: String = "Android#IconsEntry" @@ -279,6 +230,9 @@ extension Android { /// If true, exit with error when pathData exceeds 32,767 bytes. public var strictPathValidation: Bool? + /// Path to generate Figma Code Connect Kotlin file for Jetpack Compose. + public var codeConnectKotlin: String? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -315,6 +269,7 @@ extension Android { nameStyle: Common.NameStyle?, pathPrecision: Int?, strictPathValidation: Bool?, + codeConnectKotlin: String?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -331,6 +286,7 @@ extension Android { self.nameStyle = nameStyle self.pathPrecision = pathPrecision self.strictPathValidation = strictPathValidation + self.codeConnectKotlin = codeConnectKotlin self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId @@ -370,6 +326,9 @@ extension Android { /// Naming style for generated image names. public var nameStyle: Common.NameStyle? + /// Path to generate Figma Code Connect Kotlin file for Jetpack Compose. + public var codeConnectKotlin: String? + /// Figma frame name to export from. public var figmaFrameName: String? @@ -405,6 +364,7 @@ extension Android { webpOptions: Common.WebpOptions?, sourceFormat: Common.SourceFormat?, nameStyle: Common.NameStyle?, + codeConnectKotlin: String?, figmaFrameName: String?, figmaPageName: String?, figmaFileId: String?, @@ -420,6 +380,7 @@ extension Android { self.webpOptions = webpOptions self.sourceFormat = sourceFormat self.nameStyle = nameStyle + self.codeConnectKotlin = codeConnectKotlin self.figmaFrameName = figmaFrameName self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId @@ -463,6 +424,55 @@ extension Android { } } + /// Root Android platform configuration. + public struct AndroidConfig: PklRegisteredType, Decodable, Hashable, Sendable { + public static let registeredIdentifier: String = "Android#AndroidConfig" + + /// Path to main res directory. + public var mainRes: String + + /// Resource package name (R class package). + public var resourcePackage: String? + + /// Path to main src directory for Kotlin generation. + public var mainSrc: String? + + /// Path to custom Stencil templates. + public var templatesPath: String? + + /// Colors configuration entries. + public var colors: [ColorsEntry]? + + /// Icons configuration entries. + public var icons: [IconsEntry]? + + /// Images configuration entries. + public var images: [ImagesEntry]? + + /// Typography configuration. + public var typography: Typography? + + public init( + mainRes: String, + resourcePackage: String?, + mainSrc: String?, + templatesPath: String?, + colors: [ColorsEntry]?, + icons: [IconsEntry]?, + images: [ImagesEntry]?, + typography: Typography? + ) { + self.mainRes = mainRes + self.resourcePackage = resourcePackage + self.mainSrc = mainSrc + self.templatesPath = templatesPath + self.colors = colors + self.icons = icons + self.images = images + self.typography = typography + } + } + /// Load the Pkl module at the given source and evaluate it into `Android.Module`. /// /// - Parameter source: The source of the Pkl module. diff --git a/Sources/ExFigConfig/Generated/Common.pkl.swift b/Sources/ExFigConfig/Generated/Common.pkl.swift index b9a8fa34..168e117a 100644 --- a/Sources/ExFigConfig/Generated/Common.pkl.swift +++ b/Sources/ExFigConfig/Generated/Common.pkl.swift @@ -3,6 +3,12 @@ import PklSwift public enum Common {} +public protocol Common_NameProcessing: PklRegisteredType, DynamicallyEquatable, Hashable, Sendable { + var nameValidateRegexp: String? { get } + + var nameReplaceRegexp: String? { get } +} + public protocol Common_VariablesSource: Common_NameProcessing { var tokensFileId: String? { get } @@ -19,12 +25,6 @@ public protocol Common_VariablesSource: Common_NameProcessing { var primitivesModeName: String? { get } } -public protocol Common_NameProcessing: PklRegisteredType, DynamicallyEquatable, Hashable, Sendable { - var nameValidateRegexp: String? { get } - - var nameReplaceRegexp: String? { get } -} - public protocol Common_FrameSource: Common_NameProcessing { var figmaFrameName: String? { get } @@ -36,12 +36,6 @@ public protocol Common_FrameSource: Common_NameProcessing { } extension Common { - /// WebP encoding mode. - public enum WebpEncoding: String, CaseIterable, CodingKeyRepresentable, Decodable, Hashable, Sendable { - case lossy = "lossy" - case lossless = "lossless" - } - /// Naming style for generated code identifiers. public enum NameStyle: String, CaseIterable, CodingKeyRepresentable, Decodable, Hashable, Sendable { case camelCase = "camelCase" @@ -52,6 +46,12 @@ extension Common { case sCREAMING_SNAKE_CASE = "SCREAMING_SNAKE_CASE" } + /// WebP encoding mode. + public enum WebpEncoding: String, CaseIterable, CodingKeyRepresentable, Decodable, Hashable, Sendable { + case lossy = "lossy" + case lossless = "lossless" + } + /// Vector format for icons. public enum VectorFormat: String, CaseIterable, CodingKeyRepresentable, Decodable, Hashable, Sendable { case pdf = "pdf" @@ -66,6 +66,63 @@ extension Common { case svg = "svg" } + /// Common types and configurations shared across all platforms. + public struct Module: PklRegisteredType, Decodable, Hashable, Sendable { + public static let registeredIdentifier: String = "Common" + + public init() {} + } + + /// WebP encoding options. + public struct WebpOptions: PklRegisteredType, Decodable, Hashable, Sendable { + public static let registeredIdentifier: String = "Common#WebpOptions" + + /// Encoding mode. + public var encoding: WebpEncoding + + /// Quality for lossy encoding (0-100). + public var quality: Int? + + public init(encoding: WebpEncoding, quality: Int?) { + self.encoding = encoding + self.quality = quality + } + } + + /// Cache configuration for tracking Figma file versions. + public struct Cache: PklRegisteredType, Decodable, Hashable, Sendable { + public static let registeredIdentifier: String = "Common#Cache" + + /// Enable version tracking cache. + public var enabled: Bool? + + /// Custom path to cache file. + public var path: String? + + public init(enabled: Bool?, path: String?) { + self.enabled = enabled + self.path = path + } + } + + public typealias NameProcessing = Common_NameProcessing + + /// Name validation and transformation configuration. + public struct NameProcessingImpl: NameProcessing { + public static let registeredIdentifier: String = "Common#NameProcessing" + + /// Regex pattern for validating/capturing names. + public var nameValidateRegexp: String? + + /// Replacement pattern using captured groups. + public var nameReplaceRegexp: String? + + public init(nameValidateRegexp: String?, nameReplaceRegexp: String?) { + self.nameValidateRegexp = nameValidateRegexp + self.nameReplaceRegexp = nameReplaceRegexp + } + } + public typealias VariablesSource = Common_VariablesSource /// Figma Variables source configuration. @@ -124,63 +181,6 @@ extension Common { } } - public typealias NameProcessing = Common_NameProcessing - - /// Name validation and transformation configuration. - public struct NameProcessingImpl: NameProcessing { - public static let registeredIdentifier: String = "Common#NameProcessing" - - /// Regex pattern for validating/capturing names. - public var nameValidateRegexp: String? - - /// Replacement pattern using captured groups. - public var nameReplaceRegexp: String? - - public init(nameValidateRegexp: String?, nameReplaceRegexp: String?) { - self.nameValidateRegexp = nameValidateRegexp - self.nameReplaceRegexp = nameReplaceRegexp - } - } - - /// Common types and configurations shared across all platforms. - public struct Module: PklRegisteredType, Decodable, Hashable, Sendable { - public static let registeredIdentifier: String = "Common" - - public init() {} - } - - /// WebP encoding options. - public struct WebpOptions: PklRegisteredType, Decodable, Hashable, Sendable { - public static let registeredIdentifier: String = "Common#WebpOptions" - - /// Encoding mode. - public var encoding: WebpEncoding - - /// Quality for lossy encoding (0-100). - public var quality: Int? - - public init(encoding: WebpEncoding, quality: Int?) { - self.encoding = encoding - self.quality = quality - } - } - - /// Cache configuration for tracking Figma file versions. - public struct Cache: PklRegisteredType, Decodable, Hashable, Sendable { - public static let registeredIdentifier: String = "Common#Cache" - - /// Enable version tracking cache. - public var enabled: Bool? - - /// Custom path to cache file. - public var path: String? - - public init(enabled: Bool?, path: String?) { - self.enabled = enabled - self.path = path - } - } - public typealias FrameSource = Common_FrameSource /// Figma Frame source configuration. diff --git a/Tests/AndroidExportTests/AndroidCodeConnectExporterTests.swift b/Tests/AndroidExportTests/AndroidCodeConnectExporterTests.swift new file mode 100644 index 00000000..ee75a7ed --- /dev/null +++ b/Tests/AndroidExportTests/AndroidCodeConnectExporterTests.swift @@ -0,0 +1,182 @@ +import AndroidExport +import CustomDump +import ExFigCore +import XCTest + +final class AndroidCodeConnectExporterTests: XCTestCase { + // MARK: - Properties + + private static let packageName = "com.example.app" + private static let resourcePackage = "com.example.app" + private let outputURL = URL(fileURLWithPath: "/output/CodeConnect.figma.kt") + + // MARK: - Helpers + + private func makePack( + name: String, + nodeId: String? = nil, + fileId: String? = nil + ) -> AssetPair { + let image = Image( + name: name, + scale: .all, + url: URL(string: "https://example.com/\(name).svg")!, + format: "svg" + ) + let pack = ImagePack( + image: image, + nodeId: nodeId, + fileId: fileId + ) + return AssetPair(light: pack, dark: nil) + } + + // MARK: - Tests + + func testGeneratesCodeConnectWithValidAssets() throws { + let exporter = AndroidCodeConnectExporter() + let packs = [ + makePack(name: "ic_home", nodeId: "12016:2218", fileId: "abc123"), + makePack(name: "ic_settings", nodeId: "12016:2219", fileId: "abc123"), + ] + + let result = try XCTUnwrap(exporter.generateCodeConnect( + imagePacks: packs, + url: outputURL, + packageName: Self.packageName, + xmlResourcePackage: Self.resourcePackage + )) + + let generatedCode = try String(data: XCTUnwrap(result.data), encoding: .utf8) + // Verify key content markers instead of exact match (Stencil whitespace varies) + let code = try XCTUnwrap(generatedCode) + XCTAssertTrue(code.contains("package \(Self.packageName)")) + XCTAssertTrue(code.contains("import com.figma.code.connect.FigmaConnect")) + XCTAssertTrue(code.contains("import \(Self.resourcePackage).R")) + XCTAssertTrue(code.contains("@FigmaConnect(url = \"https://www.figma.com/design/abc123?node-id=12016-2218\")")) + XCTAssertTrue(code.contains("fun Asset_ic_home()")) + XCTAssertTrue(code.contains("R.drawable.ic_home")) + XCTAssertTrue(code.contains("@FigmaConnect(url = \"https://www.figma.com/design/abc123?node-id=12016-2219\")")) + XCTAssertTrue(code.contains("fun Asset_ic_settings()")) + XCTAssertTrue(code.contains("R.drawable.ic_settings")) + } + + func testReturnsNilWhenAssetsLackNodeId() throws { + let exporter = AndroidCodeConnectExporter() + let packs = [ + makePack(name: "ic_home"), + makePack(name: "ic_settings"), + ] + + let result = try exporter.generateCodeConnect( + imagePacks: packs, + url: outputURL, + packageName: Self.packageName, + xmlResourcePackage: Self.resourcePackage + ) + + XCTAssertNil(result) + } + + func testMixedAssetsOnlyIncludesThoseWithValidMetadata() throws { + let exporter = AndroidCodeConnectExporter() + let packs = [ + makePack(name: "ic_home", nodeId: "12016:2218", fileId: "abc123"), + makePack(name: "ic_settings"), // no nodeId/fileId + ] + + let result = try XCTUnwrap(exporter.generateCodeConnect( + imagePacks: packs, + url: outputURL, + packageName: Self.packageName, + xmlResourcePackage: Self.resourcePackage + )) + + let generatedCode = try String(data: XCTUnwrap(result.data), encoding: .utf8) + XCTAssertTrue(generatedCode?.contains("ic_home") == true) + XCTAssertFalse(generatedCode?.contains("ic_settings") == true) + } + + func testNodeIdColonsConvertedToHyphens() throws { + let exporter = AndroidCodeConnectExporter() + let packs = [ + makePack(name: "ic_arrow", nodeId: "12016:2218", fileId: "xyz"), + ] + + let result = try XCTUnwrap(exporter.generateCodeConnect( + imagePacks: packs, + url: outputURL, + packageName: Self.packageName, + xmlResourcePackage: Self.resourcePackage + )) + + let generatedCode = try String(data: XCTUnwrap(result.data), encoding: .utf8) + XCTAssertTrue(generatedCode?.contains("node-id=12016-2218") == true) + XCTAssertFalse(generatedCode?.contains("node-id=12016:2218") == true) + } + + func testAssetsSortedByName() throws { + let exporter = AndroidCodeConnectExporter() + let packs = [ + makePack(name: "ic_zebra", nodeId: "1:3", fileId: "f1"), + makePack(name: "ic_apple", nodeId: "1:1", fileId: "f1"), + makePack(name: "ic_mango", nodeId: "1:2", fileId: "f1"), + ] + + let result = try XCTUnwrap(exporter.generateCodeConnect( + imagePacks: packs, + url: outputURL, + packageName: Self.packageName, + xmlResourcePackage: Self.resourcePackage + )) + + let generatedCode = try XCTUnwrap(String(data: XCTUnwrap(result.data), encoding: .utf8)) + let appleIndex = try XCTUnwrap(generatedCode.range(of: "ic_apple")?.lowerBound) + let mangoIndex = try XCTUnwrap(generatedCode.range(of: "ic_mango")?.lowerBound) + let zebraIndex = try XCTUnwrap(generatedCode.range(of: "ic_zebra")?.lowerBound) + XCTAssertTrue(appleIndex < mangoIndex) + XCTAssertTrue(mangoIndex < zebraIndex) + } + + func testGranularCacheModeUsesAllAssetMetadata() throws { + let exporter = AndroidCodeConnectExporter() + let packs = [ + makePack(name: "ic_home", nodeId: "1:1", fileId: "f1"), + ] + let allMetadata = [ + AssetMetadata(name: "ic_home", nodeId: "1:1", fileId: "f1"), + AssetMetadata(name: "ic_settings", nodeId: "1:2", fileId: "f1"), + AssetMetadata(name: "ic_profile", nodeId: "1:3", fileId: "f1"), + ] + + let result = try XCTUnwrap(exporter.generateCodeConnect( + imagePacks: packs, + url: outputURL, + packageName: Self.packageName, + xmlResourcePackage: Self.resourcePackage, + allAssetMetadata: allMetadata + )) + + let generatedCode = try String(data: XCTUnwrap(result.data), encoding: .utf8) + XCTAssertTrue(generatedCode?.contains("ic_home") == true) + XCTAssertTrue(generatedCode?.contains("ic_settings") == true) + XCTAssertTrue(generatedCode?.contains("ic_profile") == true) + } + + func testOutputFileDestination() throws { + let exporter = AndroidCodeConnectExporter() + let packs = [ + makePack(name: "ic_test", nodeId: "1:1", fileId: "f1"), + ] + + let result = try XCTUnwrap(exporter.generateCodeConnect( + imagePacks: packs, + url: outputURL, + packageName: Self.packageName, + xmlResourcePackage: Self.resourcePackage + )) + + XCTAssertEqual(result.destination.file.lastPathComponent, "CodeConnect.figma.kt") + XCTAssertEqual(result.destination.directory.path, "/output") + } +} diff --git a/Tests/ExFigTests/Input/EnumBridgingTests.swift b/Tests/ExFigTests/Input/EnumBridgingTests.swift index 3c3ac5a2..0e9e976e 100644 --- a/Tests/ExFigTests/Input/EnumBridgingTests.swift +++ b/Tests/ExFigTests/Input/EnumBridgingTests.swift @@ -190,6 +190,7 @@ final class EnumBridgingTests: XCTestCase { nameStyle: pklStyle, pathPrecision: nil, strictPathValidation: nil, + codeConnectKotlin: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -215,6 +216,7 @@ final class EnumBridgingTests: XCTestCase { nameStyle: nil, pathPrecision: nil, strictPathValidation: nil, + codeConnectKotlin: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -247,6 +249,7 @@ final class EnumBridgingTests: XCTestCase { webpOptions: nil, sourceFormat: nil, nameStyle: pklStyle, + codeConnectKotlin: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, @@ -271,6 +274,7 @@ final class EnumBridgingTests: XCTestCase { webpOptions: nil, sourceFormat: nil, nameStyle: nil, + codeConnectKotlin: nil, figmaFrameName: nil, figmaPageName: nil, figmaFileId: nil, From 1f5871c8c5a71af089b9f4ca17e6567ba3b8689c Mon Sep 17 00:00:00 2001 From: alexey1312 Date: Wed, 18 Feb 2026 00:59:10 +0500 Subject: [PATCH 2/2] fix: after review --- CONFIG.md | 1 + .../AndroidCodeConnectExporter.swift | 37 +++-- Sources/AndroidExport/CLAUDE.md | 2 +- .../Resources/CodeConnect.figma.kt.stencil | 7 +- .../Export/AndroidIconsExporter.swift | 18 ++- .../Export/AndroidImagesExporter.swift | 71 +++++---- .../AndroidCodeConnectExporterTests.swift | 150 +++++++++++++----- .../2026-02-07-pkl-schema-v2/design.md | 2 +- .../2026-02-07-pkl-schema-v2/proposal.md | 2 +- .../archive/2026-02-07-pkl-schema-v2/tasks.md | 2 +- 10 files changed, 189 insertions(+), 103 deletions(-) diff --git a/CONFIG.md b/CONFIG.md index bd8f0f37..aa218037 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -679,6 +679,7 @@ images = new Android.ImagesEntry { | `scales` | `Listing?` | 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`. diff --git a/Sources/AndroidExport/AndroidCodeConnectExporter.swift b/Sources/AndroidExport/AndroidCodeConnectExporter.swift index bd7f3bda..432da2be 100644 --- a/Sources/AndroidExport/AndroidCodeConnectExporter.swift +++ b/Sources/AndroidExport/AndroidCodeConnectExporter.swift @@ -12,10 +12,10 @@ public final class AndroidCodeConnectExporter: AndroidExporter { super.init(templatesPath: templatesPath) } - /// Generates a Code Connect Kotlin file from image packs. + /// Generates a Code Connect Kotlin file from asset packs (icons or images). /// /// - Parameters: - /// - imagePacks: Image packs with nodeId and fileId for Code Connect URLs. + /// - 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. @@ -32,26 +32,19 @@ public final class AndroidCodeConnectExporter: AndroidExporter { let assets: [[String: String]] if let allMetadata = allAssetMetadata, !allMetadata.isEmpty { - assets = allMetadata.map { meta in + 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 { - let validAssets = imagePacks.filter { pack in - pack.light.nodeId != nil && pack.light.fileId != nil - } - guard !validAssets.isEmpty else { return nil } - - assets = validAssets.map { pack in - makeAssetContext( - name: pack.light.name, - nodeId: pack.light.nodeId ?? "", - fileId: pack.light.fileId ?? "" - ) + 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 } } - guard !assets.isEmpty else { return nil } - let sortedAssets = assets.sorted { ($0["name"] ?? "") < ($1["name"] ?? "") } let context: [String: Any] = [ @@ -70,14 +63,26 @@ public final class AndroidCodeConnectExporter: AndroidExporter { // 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_`). 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, diff --git a/Sources/AndroidExport/CLAUDE.md b/Sources/AndroidExport/CLAUDE.md index 3174098d..6dd4ceb5 100644 --- a/Sources/AndroidExport/CLAUDE.md +++ b/Sources/AndroidExport/CLAUDE.md @@ -20,7 +20,7 @@ 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 | | ------------------------------ | ----------------------------------------------- | -------------------------------- | diff --git a/Sources/AndroidExport/Resources/CodeConnect.figma.kt.stencil b/Sources/AndroidExport/Resources/CodeConnect.figma.kt.stencil index ab53c9ac..402c0aca 100644 --- a/Sources/AndroidExport/Resources/CodeConnect.figma.kt.stencil +++ b/Sources/AndroidExport/Resources/CodeConnect.figma.kt.stencil @@ -3,6 +3,7 @@ */ package {{ package }} +import androidx.compose.material.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.res.painterResource import com.figma.code.connect.FigmaConnect @@ -12,10 +13,10 @@ import {{ xmlResourcePackage }}.R @FigmaConnect(url = "{{ asset.figmaUrl }}") @Composable fun {{ asset.className }}() { - androidx.compose.material.Icon( - painter = painterResource(id = R.drawable.{{ asset.name }}), + Icon( + painter = painterResource(id = R.drawable.{{ asset.resourceName }}), contentDescription = null ) } -{% endfor %} \ No newline at end of file +{% endfor %} diff --git a/Sources/ExFig-Android/Export/AndroidIconsExporter.swift b/Sources/ExFig-Android/Export/AndroidIconsExporter.swift index 28cc008b..0f39374b 100644 --- a/Sources/ExFig-Android/Export/AndroidIconsExporter.swift +++ b/Sources/ExFig-Android/Export/AndroidIconsExporter.swift @@ -124,7 +124,7 @@ private extension AndroidIconsExporter { // Generate Code Connect if configured if let codeConnectFile = try generateCodeConnect( - iconPairs: iconPairs, entry: entry, platformConfig: platformConfig + iconPairs: iconPairs, entry: entry, platformConfig: platformConfig, context: context ) { allFiles.append(codeConnectFile) } @@ -168,12 +168,16 @@ private extension AndroidIconsExporter { func generateCodeConnect( iconPairs: [AssetPair], entry: AndroidIconsEntry, - platformConfig: AndroidPlatformConfig + platformConfig: AndroidPlatformConfig, + context: some IconsExportContext ) throws -> FileContents? { - guard let url = entry.codeConnectKotlinURL, - let packageName = entry.composePackageName, - let resourcePackage = platformConfig.resourcePackage - else { + 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( @@ -262,7 +266,7 @@ private extension AndroidIconsExporter { // Generate Code Connect if configured var allKotlinFiles = kotlinFiles if let codeConnectFile = try generateCodeConnect( - iconPairs: iconPairs, entry: entry, platformConfig: platformConfig + iconPairs: iconPairs, entry: entry, platformConfig: platformConfig, context: context ) { allKotlinFiles.append(codeConnectFile) } diff --git a/Sources/ExFig-Android/Export/AndroidImagesExporter.swift b/Sources/ExFig-Android/Export/AndroidImagesExporter.swift index 7e04906e..81d4acaa 100644 --- a/Sources/ExFig-Android/Export/AndroidImagesExporter.swift +++ b/Sources/ExFig-Android/Export/AndroidImagesExporter.swift @@ -115,11 +115,10 @@ private extension AndroidImagesExporter { return FileContents(destination: Destination(directory: dir, file: fileURL), dataFile: source) } - if let codeConnectFile = try generateCodeConnect( - imagePairs: imagePairs, entry: entry, platformConfig: platformConfig - ) { - allFiles.append(codeConnectFile) - } + try addCodeConnectFile( + to: &allFiles, imagePairs: imagePairs, + entry: entry, platformConfig: platformConfig, context: context + ) let filesToWrite = allFiles try await context.withSpinner("Writing files to Android project...") { @@ -180,11 +179,10 @@ private extension AndroidImagesExporter { dark: file.dark )) } - if let codeConnectFile = try generateCodeConnect( - imagePairs: imagePairs, entry: entry, platformConfig: platformConfig - ) { - collectedFiles.append(codeConnectFile) - } + try addCodeConnectFile( + to: &collectedFiles, imagePairs: imagePairs, + entry: entry, platformConfig: platformConfig, context: context + ) let finalFiles = collectedFiles @@ -246,11 +244,10 @@ private extension AndroidImagesExporter { )) } - if let codeConnectFile = try generateCodeConnect( - imagePairs: imagePairs, entry: entry, platformConfig: platformConfig - ) { - collectedFiles.append(codeConnectFile) - } + try addCodeConnectFile( + to: &collectedFiles, imagePairs: imagePairs, + entry: entry, platformConfig: platformConfig, context: context + ) let finalFiles = collectedFiles @@ -305,11 +302,10 @@ private extension AndroidImagesExporter { ) } - if let codeConnectFile = try generateCodeConnect( - imagePairs: imagePairs, entry: entry, platformConfig: platformConfig - ) { - allFiles.append(codeConnectFile) - } + try addCodeConnectFile( + to: &allFiles, imagePairs: imagePairs, + entry: entry, platformConfig: platformConfig, context: context + ) let filesToWrite = allFiles try await context.withSpinner("Writing files to Android project...") { @@ -355,11 +351,10 @@ private extension AndroidImagesExporter { ) } - if let codeConnectFile = try generateCodeConnect( - imagePairs: imagePairs, entry: entry, platformConfig: platformConfig - ) { - allFiles.append(codeConnectFile) - } + try addCodeConnectFile( + to: &allFiles, imagePairs: imagePairs, + entry: entry, platformConfig: platformConfig, context: context + ) let filesToWrite = allFiles try await context.withSpinner("Writing files to Android project...") { @@ -378,16 +373,20 @@ private extension AndroidImagesExporter { func generateCodeConnect( imagePairs: [AssetPair], entry: AndroidImagesEntry, - platformConfig: AndroidPlatformConfig + platformConfig: AndroidPlatformConfig, + context: some ImagesExportContext ) throws -> FileContents? { - guard let url = entry.codeConnectKotlinURL, - let resourcePackage = platformConfig.resourcePackage - else { + guard let url = entry.codeConnectKotlinURL else { 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) ) + // Images use resourcePackage as both the Kotlin package and R class package, + // since image Code Connect files live alongside the resource module. + // Icons use the dedicated composePackageName which may differ. return try exporter.generateCodeConnect( imagePacks: imagePairs, url: url, @@ -395,6 +394,20 @@ private extension AndroidImagesExporter { xmlResourcePackage: resourcePackage ) } + + func addCodeConnectFile( + to files: inout [FileContents], + imagePairs: [AssetPair], + entry: AndroidImagesEntry, + platformConfig: AndroidPlatformConfig, + context: some ImagesExportContext + ) throws { + if let codeConnectFile = try generateCodeConnect( + imagePairs: imagePairs, entry: entry, platformConfig: platformConfig, context: context + ) { + files.append(codeConnectFile) + } + } } // MARK: - Output Directory diff --git a/Tests/AndroidExportTests/AndroidCodeConnectExporterTests.swift b/Tests/AndroidExportTests/AndroidCodeConnectExporterTests.swift index ee75a7ed..80e6eb0a 100644 --- a/Tests/AndroidExportTests/AndroidCodeConnectExporterTests.swift +++ b/Tests/AndroidExportTests/AndroidCodeConnectExporterTests.swift @@ -1,5 +1,4 @@ import AndroidExport -import CustomDump import ExFigCore import XCTest @@ -20,6 +19,7 @@ final class AndroidCodeConnectExporterTests: XCTestCase { let image = Image( name: name, scale: .all, + // swiftlint:disable:next force_unwrapping url: URL(string: "https://example.com/\(name).svg")!, format: "svg" ) @@ -31,25 +31,31 @@ final class AndroidCodeConnectExporterTests: XCTestCase { return AssetPair(light: pack, dark: nil) } + private func generateCode( + packs: [AssetPair], + allAssetMetadata: [AssetMetadata]? = nil + ) throws -> String { + let exporter = AndroidCodeConnectExporter() + let result = try XCTUnwrap(exporter.generateCodeConnect( + imagePacks: packs, + url: outputURL, + packageName: Self.packageName, + xmlResourcePackage: Self.resourcePackage, + allAssetMetadata: allAssetMetadata + )) + let data = try XCTUnwrap(result.data) + return try XCTUnwrap(String(data: data, encoding: .utf8)) + } + // MARK: - Tests func testGeneratesCodeConnectWithValidAssets() throws { - let exporter = AndroidCodeConnectExporter() let packs = [ makePack(name: "ic_home", nodeId: "12016:2218", fileId: "abc123"), makePack(name: "ic_settings", nodeId: "12016:2219", fileId: "abc123"), ] - let result = try XCTUnwrap(exporter.generateCodeConnect( - imagePacks: packs, - url: outputURL, - packageName: Self.packageName, - xmlResourcePackage: Self.resourcePackage - )) - - let generatedCode = try String(data: XCTUnwrap(result.data), encoding: .utf8) - // Verify key content markers instead of exact match (Stencil whitespace varies) - let code = try XCTUnwrap(generatedCode) + let code = try generateCode(packs: packs) XCTAssertTrue(code.contains("package \(Self.packageName)")) XCTAssertTrue(code.contains("import com.figma.code.connect.FigmaConnect")) XCTAssertTrue(code.contains("import \(Self.resourcePackage).R")) @@ -59,6 +65,18 @@ final class AndroidCodeConnectExporterTests: XCTestCase { XCTAssertTrue(code.contains("@FigmaConnect(url = \"https://www.figma.com/design/abc123?node-id=12016-2219\")")) XCTAssertTrue(code.contains("fun Asset_ic_settings()")) XCTAssertTrue(code.contains("R.drawable.ic_settings")) + XCTAssertTrue(code.contains("import androidx.compose.material.Icon")) + } + + func testEmptyImagePacksReturnsNil() throws { + let exporter = AndroidCodeConnectExporter() + let result = try exporter.generateCodeConnect( + imagePacks: [], + url: outputURL, + packageName: Self.packageName, + xmlResourcePackage: Self.resourcePackage + ) + XCTAssertNil(result) } func testReturnsNilWhenAssetsLackNodeId() throws { @@ -78,68 +96,75 @@ final class AndroidCodeConnectExporterTests: XCTestCase { XCTAssertNil(result) } - func testMixedAssetsOnlyIncludesThoseWithValidMetadata() throws { + func testAssetWithNodeIdButNoFileIdIsFiltered() throws { let exporter = AndroidCodeConnectExporter() let packs = [ - makePack(name: "ic_home", nodeId: "12016:2218", fileId: "abc123"), - makePack(name: "ic_settings"), // no nodeId/fileId + makePack(name: "ic_home", nodeId: "1:1"), ] - let result = try XCTUnwrap(exporter.generateCodeConnect( + let result = try exporter.generateCodeConnect( imagePacks: packs, url: outputURL, packageName: Self.packageName, xmlResourcePackage: Self.resourcePackage - )) + ) - let generatedCode = try String(data: XCTUnwrap(result.data), encoding: .utf8) - XCTAssertTrue(generatedCode?.contains("ic_home") == true) - XCTAssertFalse(generatedCode?.contains("ic_settings") == true) + XCTAssertNil(result) } - func testNodeIdColonsConvertedToHyphens() throws { + func testAssetWithFileIdButNoNodeIdIsFiltered() throws { let exporter = AndroidCodeConnectExporter() let packs = [ - makePack(name: "ic_arrow", nodeId: "12016:2218", fileId: "xyz"), + makePack(name: "ic_home", fileId: "f1"), ] - let result = try XCTUnwrap(exporter.generateCodeConnect( + let result = try exporter.generateCodeConnect( imagePacks: packs, url: outputURL, packageName: Self.packageName, xmlResourcePackage: Self.resourcePackage - )) + ) - let generatedCode = try String(data: XCTUnwrap(result.data), encoding: .utf8) - XCTAssertTrue(generatedCode?.contains("node-id=12016-2218") == true) - XCTAssertFalse(generatedCode?.contains("node-id=12016:2218") == true) + XCTAssertNil(result) + } + + func testMixedAssetsOnlyIncludesThoseWithValidMetadata() throws { + let packs = [ + makePack(name: "ic_home", nodeId: "12016:2218", fileId: "abc123"), + makePack(name: "ic_settings"), // no nodeId/fileId + ] + + let code = try generateCode(packs: packs) + XCTAssertTrue(code.contains("ic_home")) + XCTAssertFalse(code.contains("ic_settings")) + } + + func testNodeIdColonsConvertedToHyphens() throws { + let packs = [ + makePack(name: "ic_arrow", nodeId: "12016:2218", fileId: "xyz"), + ] + + let code = try generateCode(packs: packs) + XCTAssertTrue(code.contains("node-id=12016-2218")) + XCTAssertFalse(code.contains("node-id=12016:2218")) } func testAssetsSortedByName() throws { - let exporter = AndroidCodeConnectExporter() let packs = [ makePack(name: "ic_zebra", nodeId: "1:3", fileId: "f1"), makePack(name: "ic_apple", nodeId: "1:1", fileId: "f1"), makePack(name: "ic_mango", nodeId: "1:2", fileId: "f1"), ] - let result = try XCTUnwrap(exporter.generateCodeConnect( - imagePacks: packs, - url: outputURL, - packageName: Self.packageName, - xmlResourcePackage: Self.resourcePackage - )) - - let generatedCode = try XCTUnwrap(String(data: XCTUnwrap(result.data), encoding: .utf8)) - let appleIndex = try XCTUnwrap(generatedCode.range(of: "ic_apple")?.lowerBound) - let mangoIndex = try XCTUnwrap(generatedCode.range(of: "ic_mango")?.lowerBound) - let zebraIndex = try XCTUnwrap(generatedCode.range(of: "ic_zebra")?.lowerBound) + let code = try generateCode(packs: packs) + let appleIndex = try XCTUnwrap(code.range(of: "ic_apple")?.lowerBound) + let mangoIndex = try XCTUnwrap(code.range(of: "ic_mango")?.lowerBound) + let zebraIndex = try XCTUnwrap(code.range(of: "ic_zebra")?.lowerBound) XCTAssertTrue(appleIndex < mangoIndex) XCTAssertTrue(mangoIndex < zebraIndex) } func testGranularCacheModeUsesAllAssetMetadata() throws { - let exporter = AndroidCodeConnectExporter() let packs = [ makePack(name: "ic_home", nodeId: "1:1", fileId: "f1"), ] @@ -149,6 +174,30 @@ final class AndroidCodeConnectExporterTests: XCTestCase { AssetMetadata(name: "ic_profile", nodeId: "1:3", fileId: "f1"), ] + let code = try generateCode(packs: packs, allAssetMetadata: allMetadata) + XCTAssertTrue(code.contains("ic_home")) + XCTAssertTrue(code.contains("ic_settings")) + XCTAssertTrue(code.contains("ic_profile")) + } + + func testEmptyAllAssetMetadataFallsBackToImagePacks() throws { + let packs = [ + makePack(name: "ic_home", nodeId: "1:1", fileId: "f1"), + ] + + let code = try generateCode(packs: packs, allAssetMetadata: []) + XCTAssertTrue(code.contains("ic_home")) + } + + func testAllAssetMetadataFiltersEmptyNodeId() throws { + let exporter = AndroidCodeConnectExporter() + let packs = [makePack(name: "ic_home", nodeId: "1:1", fileId: "f1")] + let allMetadata = [ + AssetMetadata(name: "ic_valid", nodeId: "1:1", fileId: "f1"), + AssetMetadata(name: "ic_empty_node", nodeId: "", fileId: "f1"), + AssetMetadata(name: "ic_empty_file", nodeId: "1:2", fileId: ""), + ] + let result = try XCTUnwrap(exporter.generateCodeConnect( imagePacks: packs, url: outputURL, @@ -156,11 +205,24 @@ final class AndroidCodeConnectExporterTests: XCTestCase { xmlResourcePackage: Self.resourcePackage, allAssetMetadata: allMetadata )) + let data = try XCTUnwrap(result.data) + let code = try XCTUnwrap(String(data: data, encoding: .utf8)) + XCTAssertTrue(code.contains("ic_valid")) + XCTAssertFalse(code.contains("ic_empty_node")) + XCTAssertFalse(code.contains("ic_empty_file")) + } + + func testResourceNameSanitized() throws { + let packs = [ + makePack(name: "icon-with-dashes", nodeId: "1:1", fileId: "f1"), + makePack(name: "icon.with.dots", nodeId: "1:2", fileId: "f1"), + makePack(name: "3starts_with_digit", nodeId: "1:3", fileId: "f1"), + ] - let generatedCode = try String(data: XCTUnwrap(result.data), encoding: .utf8) - XCTAssertTrue(generatedCode?.contains("ic_home") == true) - XCTAssertTrue(generatedCode?.contains("ic_settings") == true) - XCTAssertTrue(generatedCode?.contains("ic_profile") == true) + let code = try generateCode(packs: packs) + XCTAssertTrue(code.contains("R.drawable.icon_with_dashes")) + XCTAssertTrue(code.contains("R.drawable.icon_with_dots")) + XCTAssertTrue(code.contains("R.drawable._3starts_with_digit")) } func testOutputFileDestination() throws { diff --git a/openspec/changes/archive/2026-02-07-pkl-schema-v2/design.md b/openspec/changes/archive/2026-02-07-pkl-schema-v2/design.md index b8d6f395..bf4e1bc7 100644 --- a/openspec/changes/archive/2026-02-07-pkl-schema-v2/design.md +++ b/openspec/changes/archive/2026-02-07-pkl-schema-v2/design.md @@ -2,7 +2,7 @@ ## Context -ExFig v2.0 перешёл на PKL-конфигурацию с plugin architecture. Реальный проект Oymyakon-Atoms-iOS выявил ограничение: `xcassetsPath`, `templatesPath` задаются на уровне `iOSConfig`, а `figma.lightFileId` — на корневом уровне `figma`. Все entries в одном конфиге вынуждены использовать одинаковые значения, что приводит к 6 отдельным PKL-файлам вместо одного. +ExFig v2.0 перешёл на PKL-конфигурацию с plugin architecture. Реальный проект выявил ограничение: `xcassetsPath`, `templatesPath` задаются на уровне `iOSConfig`, а `figma.lightFileId` — на корневом уровне `figma`. Все entries в одном конфиге вынуждены использовать одинаковые значения, что приводит к 6 отдельным PKL-файлам вместо одного. Текущие PKL-схемы также не используют default values — каждый конфиг обязан явно указывать очевидные значения (`nameStyle = "camelCase"`, `format = "pdf"`). diff --git a/openspec/changes/archive/2026-02-07-pkl-schema-v2/proposal.md b/openspec/changes/archive/2026-02-07-pkl-schema-v2/proposal.md index 2bcd056d..bd2f1ad8 100644 --- a/openspec/changes/archive/2026-02-07-pkl-schema-v2/proposal.md +++ b/openspec/changes/archive/2026-02-07-pkl-schema-v2/proposal.md @@ -6,7 +6,7 @@ ## Why -Реальные проекты (Oymyakon-Atoms-iOS) вынуждены использовать **6 отдельных PKL-файлов** для одного проекта, потому что `xcassetsPath`, `templatesPath` живут на уровне `iOSConfig`, а `figma.lightFileId` — на корневом уровне. Все entries в одном конфиге обязаны использовать одни и те же значения. Это приводит к фрагментации конфигурации, дублированию общих настроек и усложнению batch-обработки. +Реальные проекты вынуждены использовать **6 отдельных PKL-файлов** для одного проекта, потому что `xcassetsPath`, `templatesPath` живут на уровне `iOSConfig`, а `figma.lightFileId` — на корневом уровне. Все entries в одном конфиге обязаны использовать одни и те же значения. Это приводит к фрагментации конфигурации, дублированию общих настроек и усложнению batch-обработки. Кроме того, PKL-схемы не используют возможности default values, из-за чего каждый конфиг обязан указывать очевидные значения вроде `nameStyle = "camelCase"` и `format = "pdf"`. diff --git a/openspec/changes/archive/2026-02-07-pkl-schema-v2/tasks.md b/openspec/changes/archive/2026-02-07-pkl-schema-v2/tasks.md index 0f78af23..1e26a90c 100644 --- a/openspec/changes/archive/2026-02-07-pkl-schema-v2/tasks.md +++ b/openspec/changes/archive/2026-02-07-pkl-schema-v2/tasks.md @@ -251,7 +251,7 @@ Track 5 зависит от всех треков. - [x] 5.1.3 Run `./bin/mise run build` — compiles - [x] 5.1.4 Run `./bin/mise run test` — all tests pass (2151 tests) - [x] 5.1.5 Run `./bin/mise run format && ./bin/mise run lint` — no issues -- [x] 5.1.6 Create unified Oymyakon-style test config (6-in-1) and verify it parses +- [x] 5.1.6 Create unified multi-config test config (6-in-1) and verify it parses - [x] 5.1.7 Verify backward compatibility: existing example configs work unchanged ### 5.2 Documentation