diff --git a/.claude/rules/api-reference.md b/.claude/rules/api-reference.md index 4b291d7c..deacccd4 100644 --- a/.claude/rules/api-reference.md +++ b/.claude/rules/api-reference.md @@ -41,6 +41,17 @@ When Figma API response structure differs from project models, check: 2. Figma API docs - actual response schema 3. Create/update `Decodable` structs to match API response +### Components Response Key Types + +| Type | Fields | Purpose | +| ----------------------- | ------------------------ | ------------------------------------------------ | +| `Component` | key, nodeId, name, description, containingFrame | Figma component metadata | +| `ContainingFrame` | nodeId, name, pageId, pageName, backgroundColor, containingComponentSet | Parent frame info | +| `ContainingComponentSet`| nodeId, name | Parent COMPONENT_SET for variant components | + +`containingComponentSet` is present when a component is a variant inside a component set (e.g., `RTL=Off` variant). +Used to get the real icon name from `componentSet.name` instead of the variant name. + ## Rate Limits **Official docs:** https://developers.figma.com/docs/rest-api/rate-limits/ diff --git a/CLAUDE.md b/CLAUDE.md index 78658767..0d8867c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ and Flutter projects. # Build & Test ./bin/mise run build # Debug build ./bin/mise run build:release # Release build -./bin/mise run test # All tests +./bin/mise run test # All tests (prefer over test:filter when 3+ files changed) # Linux: swift build --build-tests && swift test --skip-build --parallel ./bin/mise run test:filter NAME # Filter by target/class/method ./bin/mise run test:file FILE # Run tests for specific file @@ -182,6 +182,33 @@ Tests/ # Test targets mirror source structure ## Code Patterns +### Modifying Loader Configs (IconsLoaderConfig / ImagesLoaderConfig) + +When adding fields to loader configs, update ALL construction sites: + +1. Factory methods (`forIOS`, `forAndroid`, `forFlutter`, `forWeb`, `defaultConfig`) +2. Context implementations (`Sources/ExFigCLI/Context/*ExportContextImpl.swift`) — direct constructions in `loadIcons`/`loadImages` +3. Test files (`IconsLoaderConfigTests.swift`, `EnumBridgingTests.swift`) — direct init calls + +When adding fields to `FrameSource` (PKL) / `SourceInput` (ExFigCore), also update: + +4. Entry bridge methods (`iconsSourceInput()`/`imagesSourceInput()`) in ALL `Sources/ExFig-*/Config/*Entry.swift` +5. Inline `SourceInput(` constructions in exporters (`iOSImagesExporter.svgSourceInput`, `AndroidImagesExporter.loadAndProcessSVG`) +6. "Through" tests in `IconsLoaderConfigTests` — use `source.field` not hardcoded `nil` + +### Module Boundaries + +ExFigCore does NOT import FigmaAPI. Constants on `Component` (FigmaAPI, extended in ExFigCLI) are +not accessible from ExFigCore types (`IconsSourceInput`, `ImagesSourceInput`). Keep default values +as string literals in ExFigCore inits; use shared constants only within ExFigCLI. + +### RTL Detection Design + +- `Component.iconName`: uses `containingComponentSet.name` for variants, own `name` otherwise +- `Component.defaultRTLProperty = "RTL"`: shared constant in ExFigCLI for the magic string +- PNG images intentionally do NOT carry `isRTL` — raster images skip mirroring by design +- `buildPairedComponents` must use `iconName` (not `name`) — variant `name` is `"RTL=Off"`, not the icon name + ### Adding a CLI Command 1. Create `Sources/ExFigCLI/Subcommands/NewCommand.swift` implementing `AsyncParsableCommand` @@ -264,15 +291,17 @@ NooraUI.formatLink("url", useColors: true) // underlined primary ## Troubleshooting -| Problem | Solution | -| --------------------- | ------------------------------------------------------------------------------------- | -| Build fails | `swift package clean && swift build` | -| Tests fail | Check `FIGMA_PERSONAL_TOKEN` is set | -| Formatting fails | Run `./bin/mise run setup` to install tools | -| Template errors | Check Stencil syntax and context variables | -| Linux test hangs | Build first: `swift build --build-tests`, then `swift test --skip-build --parallel` | -| Android pathData long | Simplify in Figma or use `--strict-path-validation` | -| PKL parse error 1 | Check `PklError.message` — actual error is in `.message`, not `.localizedDescription` | +| Problem | Solution | +| ----------------------- | ---------------------------------------------------------------------------------------- | +| pkl-gen-swift not found | Build from SPM: `swift build --product pkl-gen-swift`, then `.build/debug/pkl-gen-swift` | +| PKL FrameSource change | Update ALL entry init calls in tests (EnumBridgingTests, IconsLoaderConfigTests) | +| Build fails | `swift package clean && swift build` | +| Tests fail | Check `FIGMA_PERSONAL_TOKEN` is set | +| Formatting fails | Run `./bin/mise run setup` to install tools | +| Template errors | Check Stencil syntax and context variables | +| Linux test hangs | Build first: `swift build --build-tests`, then `swift test --skip-build --parallel` | +| Android pathData long | Simplify in Figma or use `--strict-path-validation` | +| PKL parse error 1 | Check `PklError.message` — actual error is in `.message`, not `.localizedDescription` | ## Additional Rules diff --git a/CONFIG.md b/CONFIG.md index c56a4adf..232c135a 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -311,6 +311,27 @@ typography = new Common.Typography { } ``` +### FrameSource (Inherited Fields) + +All Icons and Images entries across platforms extend `Common.FrameSource`, which provides: + +| Field | Type | Default | Description | +| -------------------- | --------- | ------- | ------------------------------------------------------- | +| `figmaFrameName` | `String?` | — | Override Figma frame name for this entry | +| `figmaFileId` | `String?` | — | Override Figma file ID for this entry | +| `rtlProperty` | `String?` | `"RTL"` | Figma component property name for RTL variant detection | +| `nameValidateRegexp` | `String?` | — | Regex pattern for name validation | +| `nameReplaceRegexp` | `String?` | — | Replacement pattern using captured groups | + +**RTL Detection:** When `rtlProperty` is set (default `"RTL"`), ExFig detects RTL support via Figma +COMPONENT_SET variant properties. Components with `RTL=On` variant are automatically skipped (iOS/Android +handle mirroring at runtime). Components with `RTL=Off` variant are marked as RTL-supported, and the icon +name is taken from the COMPONENT_SET name instead of the variant name. + +Priority: variant property > description-based detection (legacy fallback: if description contains "rtl"). + +Set `rtlProperty = null` to disable variant-based detection and use only description-based fallback. + --- ## iOS @@ -410,8 +431,8 @@ icons = new iOS.IconsEntry { } ``` -`iOS.IconsEntry` extends `Common.FrameSource`, inheriting `figmaFrameName`, `nameValidateRegexp`, and -`nameReplaceRegexp`. +`iOS.IconsEntry` extends `Common.FrameSource`, inheriting `figmaFrameName`, `figmaFileId`, `rtlProperty`, +`nameValidateRegexp`, and `nameReplaceRegexp`. | Field | Type | Required | Description | | ------------------------------- | ------------------ | -------- | ------------------------------------------------------------ | @@ -427,7 +448,7 @@ icons = new iOS.IconsEntry { | `renderModeOriginalSuffix` | `String?` | No | Suffix for original render mode | | `renderModeTemplateSuffix` | `String?` | No | Suffix for template render mode | -**Inherited from `FrameSource`:** `figmaFrameName`, `nameValidateRegexp`, `nameReplaceRegexp`. +**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`. ### iOS Images @@ -468,7 +489,7 @@ images = new iOS.ImagesEntry { | `renderModeOriginalSuffix` | `String?` | No | Suffix for original render mode | | `renderModeTemplateSuffix` | `String?` | No | Suffix for template render mode | -**Inherited from `FrameSource`:** `figmaFrameName`, `nameValidateRegexp`, `nameReplaceRegexp`. +**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`. **HEIC Options:** @@ -626,7 +647,7 @@ icons = new Android.IconsEntry { | `pathPrecision` | `Int(1-6)?` | No | Coordinate precision for pathData (default: 4) | | `strictPathValidation` | `Boolean?` | No | Error on pathData > 32,767 bytes (default: false) | -**Inherited from `FrameSource`:** `figmaFrameName`, `nameValidateRegexp`, `nameReplaceRegexp`. +**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`. ### Android Images @@ -651,7 +672,7 @@ images = new Android.ImagesEntry { | `webpOptions` | `WebpOptions?` | No | WebP encoding options (when format is `"webp"`) | | `sourceFormat` | `SourceFormat?` | No | Source from Figma: `"png"` (default) or `"svg"` | -**Inherited from `FrameSource`:** `figmaFrameName`, `nameValidateRegexp`, `nameReplaceRegexp`. +**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`. **WebP Options:** @@ -738,7 +759,7 @@ icons = new Flutter.IconsEntry { | `className` | `String?` | No | Class name (default: `AppIcons`) | | `nameStyle` | `NameStyle?` | No | Name style for generated names | -**Inherited from `FrameSource`:** `figmaFrameName`, `nameValidateRegexp`, `nameReplaceRegexp`. +**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`. ### Flutter Images @@ -769,7 +790,7 @@ images = new Flutter.ImagesEntry { | `sourceFormat` | `SourceFormat?` | No | Source from Figma: `"png"` or `"svg"` | | `nameStyle` | `NameStyle?` | No | Name style for generated names | -**Inherited from `FrameSource`:** `figmaFrameName`, `nameValidateRegexp`, `nameReplaceRegexp`. +**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`. --- @@ -841,7 +862,7 @@ icons = new Web.IconsEntry { | `iconSize` | `Int?` | No | Icon size in pixels for viewBox (default: 24) | | `nameStyle` | `NameStyle?` | No | Name style for generated names | -**Inherited from `FrameSource`:** `figmaFrameName`, `nameValidateRegexp`, `nameReplaceRegexp`. +**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`. ### Web Images @@ -859,7 +880,7 @@ images = new Web.ImagesEntry { | `assetsDirectory` | `String?` | No | Directory for raw image assets | | `generateReactComponents` | `Boolean?` | No | Generate React TSX components (default: true) | -**Inherited from `FrameSource`:** `figmaFrameName`, `nameValidateRegexp`, `nameReplaceRegexp`. +**Inherited from `FrameSource`:** `figmaFrameName`, `figmaFileId`, `rtlProperty`, `nameValidateRegexp`, `nameReplaceRegexp`. --- diff --git a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift index dc271299..a3c0aecb 100644 --- a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift +++ b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift @@ -20,6 +20,7 @@ public extension Android.IconsEntry { format: .svg, useSingleFile: darkFileId == nil, darkModeSuffix: "_dark", + rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp ) diff --git a/Sources/ExFig-Android/Config/AndroidImagesEntry.swift b/Sources/ExFig-Android/Config/AndroidImagesEntry.swift index f1dc55f2..328deac6 100644 --- a/Sources/ExFig-Android/Config/AndroidImagesEntry.swift +++ b/Sources/ExFig-Android/Config/AndroidImagesEntry.swift @@ -38,6 +38,7 @@ public extension Android.ImagesEntry { scales: effectiveScales, useSingleFile: darkFileId == nil, darkModeSuffix: "_dark", + rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp ) diff --git a/Sources/ExFig-Android/Export/AndroidImagesExporter.swift b/Sources/ExFig-Android/Export/AndroidImagesExporter.swift index af9d2efa..51ddcaa1 100644 --- a/Sources/ExFig-Android/Export/AndroidImagesExporter.swift +++ b/Sources/ExFig-Android/Export/AndroidImagesExporter.swift @@ -373,6 +373,7 @@ private extension AndroidImagesExporter { scales: [1.0], useSingleFile: true, darkModeSuffix: "_dark", + rtlProperty: entry.rtlProperty, nameValidateRegexp: entry.nameValidateRegexp, nameReplaceRegexp: entry.nameReplaceRegexp ) diff --git a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift index d9cf85ea..def455c4 100644 --- a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift +++ b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift @@ -16,6 +16,7 @@ public extension Flutter.IconsEntry { frameName: figmaFrameName ?? "Icons", useSingleFile: darkFileId == nil, darkModeSuffix: "_dark", + rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp ) diff --git a/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift b/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift index ac38fae5..24ef8141 100644 --- a/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift +++ b/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift @@ -21,6 +21,7 @@ public extension Flutter.ImagesEntry { scales: effectiveScales, useSingleFile: darkFileId == nil, darkModeSuffix: "_dark", + rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp ) @@ -48,6 +49,7 @@ public extension Flutter.ImagesEntry { scales: [1.0], useSingleFile: darkFileId == nil, darkModeSuffix: "_dark", + rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp ) diff --git a/Sources/ExFig-Web/Config/WebIconsEntry.swift b/Sources/ExFig-Web/Config/WebIconsEntry.swift index c5fc13a0..3fcae601 100644 --- a/Sources/ExFig-Web/Config/WebIconsEntry.swift +++ b/Sources/ExFig-Web/Config/WebIconsEntry.swift @@ -16,6 +16,7 @@ public extension Web.IconsEntry { frameName: figmaFrameName ?? "Icons", useSingleFile: darkFileId == nil, darkModeSuffix: "_dark", + rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp ) diff --git a/Sources/ExFig-Web/Config/WebImagesEntry.swift b/Sources/ExFig-Web/Config/WebImagesEntry.swift index ea8a79d5..7d06d73b 100644 --- a/Sources/ExFig-Web/Config/WebImagesEntry.swift +++ b/Sources/ExFig-Web/Config/WebImagesEntry.swift @@ -18,6 +18,7 @@ public extension Web.ImagesEntry { scales: [1.0], useSingleFile: darkFileId == nil, darkModeSuffix: "_dark", + rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp ) diff --git a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift index 20c165fe..2b5d2a5c 100644 --- a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift +++ b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift @@ -23,6 +23,7 @@ public extension iOS.IconsEntry { renderModeDefaultSuffix: renderModeDefaultSuffix, renderModeOriginalSuffix: renderModeOriginalSuffix, renderModeTemplateSuffix: renderModeTemplateSuffix, + rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp ) diff --git a/Sources/ExFig-iOS/Config/iOSImagesEntry.swift b/Sources/ExFig-iOS/Config/iOSImagesEntry.swift index 797023fe..2223d2f9 100644 --- a/Sources/ExFig-iOS/Config/iOSImagesEntry.swift +++ b/Sources/ExFig-iOS/Config/iOSImagesEntry.swift @@ -20,6 +20,7 @@ public extension iOS.ImagesEntry { scales: effectiveScales, useSingleFile: darkFileId == nil, darkModeSuffix: "_dark", + rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp ) diff --git a/Sources/ExFig-iOS/Export/iOSImagesExporter.swift b/Sources/ExFig-iOS/Export/iOSImagesExporter.swift index 8a5035b0..871dc2c0 100644 --- a/Sources/ExFig-iOS/Export/iOSImagesExporter.swift +++ b/Sources/ExFig-iOS/Export/iOSImagesExporter.swift @@ -476,6 +476,7 @@ private extension iOSImagesEntry { scales: [1.0], useSingleFile: true, darkModeSuffix: "_dark", + rtlProperty: rtlProperty, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp ) diff --git a/Sources/ExFigCLI/Context/IconsExportContextImpl.swift b/Sources/ExFigCLI/Context/IconsExportContextImpl.swift index 6fe48c06..6a3d8476 100644 --- a/Sources/ExFigCLI/Context/IconsExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/IconsExportContextImpl.swift @@ -85,7 +85,8 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { renderMode: source.renderMode, renderModeDefaultSuffix: source.renderModeDefaultSuffix, renderModeOriginalSuffix: source.renderModeOriginalSuffix, - renderModeTemplateSuffix: source.renderModeTemplateSuffix + renderModeTemplateSuffix: source.renderModeTemplateSuffix, + rtlProperty: source.rtlProperty ) let loader = IconsLoader( @@ -175,7 +176,8 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { renderMode: source.renderMode, renderModeDefaultSuffix: source.renderModeDefaultSuffix, renderModeOriginalSuffix: source.renderModeOriginalSuffix, - renderModeTemplateSuffix: source.renderModeTemplateSuffix + renderModeTemplateSuffix: source.renderModeTemplateSuffix, + rtlProperty: source.rtlProperty ) let loader = IconsLoader( diff --git a/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift b/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift index 59eb97d2..8fe21cf3 100644 --- a/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift @@ -91,7 +91,8 @@ struct ImagesExportContextImpl: ImagesExportContextWithGranularCache { frameName: source.frameName, scales: source.scales, format: nil, // Format is determined by platform exporter - sourceFormat: loaderSourceFormat + sourceFormat: loaderSourceFormat, + rtlProperty: source.rtlProperty ) let loader = ImagesLoader( @@ -367,7 +368,8 @@ struct ImagesExportContextImpl: ImagesExportContextWithGranularCache { frameName: source.frameName, scales: source.scales, format: nil, - sourceFormat: loaderSourceFormat + sourceFormat: loaderSourceFormat, + rtlProperty: source.rtlProperty ) let loader = ImagesLoader( diff --git a/Sources/ExFigCLI/Loaders/IconsLoader.swift b/Sources/ExFigCLI/Loaders/IconsLoader.swift index 0736aee9..9df79810 100644 --- a/Sources/ExFigCLI/Loaders/IconsLoader.swift +++ b/Sources/ExFigCLI/Loaders/IconsLoader.swift @@ -41,6 +41,9 @@ struct IconsLoaderConfig: Sendable { let renderModeOriginalSuffix: String? let renderModeTemplateSuffix: String? + /// Figma component property name for RTL variant detection. + let rtlProperty: String? + /// Creates config for a specific iOS icons entry. static func forIOS(entry: iOSIconsEntry, params: PKLConfig) -> IconsLoaderConfig { IconsLoaderConfig( @@ -50,7 +53,8 @@ struct IconsLoaderConfig: Sendable { renderMode: entry.coreRenderMode, renderModeDefaultSuffix: entry.renderModeDefaultSuffix, renderModeOriginalSuffix: entry.renderModeOriginalSuffix, - renderModeTemplateSuffix: entry.renderModeTemplateSuffix + renderModeTemplateSuffix: entry.renderModeTemplateSuffix, + rtlProperty: entry.rtlProperty ) } @@ -63,7 +67,8 @@ struct IconsLoaderConfig: Sendable { renderMode: nil, renderModeDefaultSuffix: nil, renderModeOriginalSuffix: nil, - renderModeTemplateSuffix: nil + renderModeTemplateSuffix: nil, + rtlProperty: entry.rtlProperty ) } @@ -76,7 +81,8 @@ struct IconsLoaderConfig: Sendable { renderMode: nil, renderModeDefaultSuffix: nil, renderModeOriginalSuffix: nil, - renderModeTemplateSuffix: nil + renderModeTemplateSuffix: nil, + rtlProperty: entry.rtlProperty ) } @@ -89,7 +95,8 @@ struct IconsLoaderConfig: Sendable { renderMode: nil, renderModeDefaultSuffix: nil, renderModeOriginalSuffix: nil, - renderModeTemplateSuffix: nil + renderModeTemplateSuffix: nil, + rtlProperty: entry.rtlProperty ) } @@ -102,7 +109,8 @@ struct IconsLoaderConfig: Sendable { renderMode: nil, renderModeDefaultSuffix: nil, renderModeOriginalSuffix: nil, - renderModeTemplateSuffix: nil + renderModeTemplateSuffix: nil, + rtlProperty: Component.defaultRTLProperty ) } } @@ -172,6 +180,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { frameName: frameName, params: formatParams, filter: filter, + rtlProperty: config.rtlProperty, onBatchProgress: onBatchProgress ) @@ -211,6 +220,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { frameName: self.frameName, params: formatParams, filter: filter, + rtlProperty: self.config.rtlProperty, onBatchProgress: onBatchProgress ).map { self.updateRenderMode($0) } return (key, icons) @@ -283,6 +293,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { params: formatParams, filter: filter, darkModeSuffix: darkSuffix, + rtlProperty: config.rtlProperty, onBatchProgress: onBatchProgress ) @@ -345,6 +356,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { frameName: self.frameName, params: formatParams, filter: filter, + rtlProperty: self.config.rtlProperty, onBatchProgress: onBatchProgress ) diff --git a/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift b/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift index 3c7fbe05..3d98bfd4 100644 --- a/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift +++ b/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift @@ -69,20 +69,31 @@ class ImageLoaderBase: @unchecked Sendable { // MARK: - Component Loading /// Fetches image components from a Figma file filtered by frame name and platform. + /// When `rtlProperty` is set, RTL=On variants are filtered out. func fetchImageComponents( fileId: String, frameName: String, - filter: String? = nil + filter: String? = nil, + rtlProperty: String? = Component.defaultRTLProperty ) async throws -> [NodeId: Component] { var components = try await loadComponents(fileId: fileId) .filter { $0.containingFrame.name == frameName && $0.useForPlatform(platform) } + // Skip RTL=On variants: the base (RTL=Off) icon is sufficient — + // platforms mirror it at runtime (iOS languageDirection, Android autoMirrored). + let beforeRTLFilter = components.count + components = components.filter { !$0.shouldSkipAsRTLVariant(propertyName: rtlProperty) } + let rtlSkipped = beforeRTLFilter - components.count + if rtlSkipped > 0 { + logger.info("Filtered out \(rtlSkipped) RTL=On variant(s) from '\(frameName)'") + } + if let filter { let assetsFilter = AssetsFilter(filter: filter) components = components.filter { component -> Bool in - assetsFilter.match(name: component.name) + assetsFilter.match(name: component.iconName) } } @@ -109,12 +120,14 @@ class ImageLoaderBase: @unchecked Sendable { func fetchImageComponentsWithGranularCache( fileId: String, frameName: String, - filter: String? = nil + filter: String? = nil, + rtlProperty: String? = Component.defaultRTLProperty ) async throws -> GranularFilterResult { let allComponents = try await fetchImageComponents( fileId: fileId, frameName: frameName, - filter: filter + filter: filter, + rtlProperty: rtlProperty ) logger.debug( @@ -122,9 +135,9 @@ class ImageLoaderBase: @unchecked Sendable { ) // Build metadata for all assets (for Code Connect and template generation) - // Names can be derived with allAssetMetadata.map(\.name) + // Use iconName to get the real name (component set name for variants) let allAssetMetadata = allComponents.map { nodeId, component in - AssetMetadata(name: component.name, nodeId: nodeId, fileId: fileId) + AssetMetadata(name: component.iconName, nodeId: nodeId, fileId: fileId) } guard let manager = granularCacheManager, !allComponents.isEmpty else { @@ -179,13 +192,14 @@ class ImageLoaderBase: @unchecked Sendable { fileId: String, frameName: String, filter: String? = nil, - darkModeSuffix: String + darkModeSuffix: String, + rtlProperty: String? = Component.defaultRTLProperty ) async throws -> GranularFilterResult { let allComponents = try await fetchImageComponents( - fileId: fileId, frameName: frameName, filter: filter + fileId: fileId, frameName: frameName, filter: filter, rtlProperty: rtlProperty ) let allAssetMetadata = allComponents.map { nodeId, component in - AssetMetadata(name: component.name, nodeId: nodeId, fileId: fileId) + AssetMetadata(name: component.iconName, nodeId: nodeId, fileId: fileId) } guard let manager = granularCacheManager, !allComponents.isEmpty else { @@ -238,10 +252,10 @@ class ImageLoaderBase: @unchecked Sendable { darkModeSuffix: String ) -> [NodeId: Component] { let changedBaseNames = Set(changedComponents.values.map { - baseName(for: $0.name, darkModeSuffix: darkModeSuffix) + baseName(for: $0.iconName, darkModeSuffix: darkModeSuffix) }) return allComponents.filter { _, component in - changedBaseNames.contains(baseName(for: component.name, darkModeSuffix: darkModeSuffix)) + changedBaseNames.contains(baseName(for: component.iconName, darkModeSuffix: darkModeSuffix)) } } @@ -260,16 +274,18 @@ class ImageLoaderBase: @unchecked Sendable { frameName: String, params: FormatParams, filter: String? = nil, + rtlProperty: String? = Component.defaultRTLProperty, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> [ImagePack] { let imagesDict = try await fetchImageComponents( - fileId: fileId, frameName: frameName, filter: filter + fileId: fileId, frameName: frameName, filter: filter, rtlProperty: rtlProperty ) return try await loadVectorImagesFromComponents( fileId: fileId, frameName: frameName, components: imagesDict, params: params, + rtlProperty: rtlProperty, onBatchProgress: onBatchProgress ) } @@ -286,16 +302,18 @@ class ImageLoaderBase: @unchecked Sendable { frameName: String, params: FormatParams, filter: String? = nil, + rtlProperty: String? = Component.defaultRTLProperty, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> ImagesWithHashesResult { let filterResult = try await fetchImageComponentsWithGranularCache( - fileId: fileId, frameName: frameName, filter: filter + fileId: fileId, frameName: frameName, filter: filter, rtlProperty: rtlProperty ) return try await loadVectorImagesFromGranularFilterResult( fileId: fileId, frameName: frameName, filterResult: filterResult, params: params, + rtlProperty: rtlProperty, onBatchProgress: onBatchProgress ) } @@ -311,19 +329,22 @@ class ImageLoaderBase: @unchecked Sendable { params: FormatParams, filter: String? = nil, darkModeSuffix: String, + rtlProperty: String? = Component.defaultRTLProperty, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> ImagesWithHashesResult { let filterResult = try await fetchImageComponentsWithGranularCacheAndPairing( fileId: fileId, frameName: frameName, filter: filter, - darkModeSuffix: darkModeSuffix + darkModeSuffix: darkModeSuffix, + rtlProperty: rtlProperty ) return try await loadVectorImagesFromGranularFilterResult( fileId: fileId, frameName: frameName, filterResult: filterResult, params: params, + rtlProperty: rtlProperty, onBatchProgress: onBatchProgress ) } @@ -336,6 +357,7 @@ class ImageLoaderBase: @unchecked Sendable { frameName: String, components: [NodeId: Component], params: FormatParams, + rtlProperty: String? = Component.defaultRTLProperty, onBatchProgress: @escaping BatchProgressCallback ) async throws -> [ImagePack] { var imagesDict = components @@ -360,7 +382,8 @@ class ImageLoaderBase: @unchecked Sendable { imagesDict: imagesDict, imageIdToImagePath: imageIdToImagePath, format: params.format, - fileId: fileId + fileId: fileId, + rtlProperty: rtlProperty ) } @@ -370,6 +393,7 @@ class ImageLoaderBase: @unchecked Sendable { frameName: String, filterResult: GranularFilterResult, params: FormatParams, + rtlProperty: String? = Component.defaultRTLProperty, onBatchProgress: @escaping BatchProgressCallback ) async throws -> ImagesWithHashesResult { if filterResult.allSkipped { @@ -386,6 +410,7 @@ class ImageLoaderBase: @unchecked Sendable { frameName: frameName, components: filterResult.components, params: params, + rtlProperty: rtlProperty, onBatchProgress: onBatchProgress ) @@ -400,7 +425,7 @@ class ImageLoaderBase: @unchecked Sendable { /// Filters out components with empty names and logs warnings for them. private func filterEmptyNameComponents(_ components: [NodeId: Component]) -> [NodeId: Component] { components.filter { _, component in - if component.name.trimmingCharacters(in: .whitespaces).isEmpty { + if component.iconName.trimmingCharacters(in: .whitespaces).isEmpty { logger.warning( """ Found a component with empty name. @@ -434,10 +459,11 @@ class ImageLoaderBase: @unchecked Sendable { imagesDict: [NodeId: Component], imageIdToImagePath: [NodeId: ImagePath], format: String, - fileId: String + fileId: String, + rtlProperty: String? = Component.defaultRTLProperty ) -> [ImagePack] { let groups = Dictionary(grouping: imagesDict) { - $1.name.parseNameAndIdiom(platform: platform).name + $1.iconName.parseNameAndIdiom(platform: platform).name } return groups.compactMap { packName, components -> ImagePack? in @@ -447,14 +473,14 @@ class ImageLoaderBase: @unchecked Sendable { else { return nil } - let (name, idiom) = component.name.parseNameAndIdiom(platform: platform) + let (name, idiom) = component.iconName.parseNameAndIdiom(platform: platform) return Image( name: name, scale: .all, idiom: idiom, url: url, format: format, - isRTL: component.useRTL() + isRTL: component.useRTL(rtlProperty: rtlProperty) ) } let primaryNodeId = components.first?.0 @@ -474,10 +500,11 @@ class ImageLoaderBase: @unchecked Sendable { frameName: String, filter: String? = nil, scales: [Double], + rtlProperty: String? = Component.defaultRTLProperty, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> [ImagePack] { let imagesDict = try await fetchImageComponents( - fileId: fileId, frameName: frameName, filter: filter + fileId: fileId, frameName: frameName, filter: filter, rtlProperty: rtlProperty ) return try await loadPNGImagesFromComponents( fileId: fileId, @@ -499,10 +526,11 @@ class ImageLoaderBase: @unchecked Sendable { frameName: String, filter: String? = nil, scales: [Double], + rtlProperty: String? = Component.defaultRTLProperty, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> ImagesWithHashesResult { let filterResult = try await fetchImageComponentsWithGranularCache( - fileId: fileId, frameName: frameName, filter: filter + fileId: fileId, frameName: frameName, filter: filter, rtlProperty: rtlProperty ) if filterResult.allSkipped { @@ -572,12 +600,12 @@ class ImageLoaderBase: @unchecked Sendable { fileId: String ) -> [ImagePack] { let groups = Dictionary(grouping: imagesDict) { - $1.name.parseNameAndIdiom(platform: platform).name + $1.iconName.parseNameAndIdiom(platform: platform).name } return groups.compactMap { packName, components -> ImagePack? in let packImages = components.flatMap { nodeId, component -> [Image] in - let (name, idiom) = component.name.parseNameAndIdiom(platform: platform) + let (name, idiom) = component.iconName.parseNameAndIdiom(platform: platform) return scales.compactMap { scale -> Image? in guard let urlString = imagesByScale[scale]?[nodeId], let url = URL(string: urlString) @@ -835,8 +863,41 @@ public extension Component { } } - /// Checks if component should use RTL layout based on its description. - func useRTL() -> Bool { + /// Real icon name: component set name for variants, own name otherwise. + var iconName: String { + containingFrame.containingComponentSet?.name ?? name + } + + /// Extracts the RTL variant value from the component name (e.g. "Off" from "RTL=Off"). + /// Parses "Property=Value, Property2=Value2" format used by Figma variant components. + func rtlVariantValue(propertyName: String) -> String? { + for pair in name.split(separator: ",") { + let trimmed = pair.trimmingCharacters(in: .whitespaces) + let parts = trimmed.split(separator: "=", maxSplits: 1) + .map { $0.trimmingCharacters(in: .whitespaces) } + if parts.count == 2, parts[0] == propertyName { + return parts[1] + } + } + return nil + } + + /// Default Figma component property name for RTL variant detection. + static let defaultRTLProperty = "RTL" + + /// Whether this component should be skipped (RTL=On variant). + func shouldSkipAsRTLVariant(propertyName: String?) -> Bool { + guard let prop = propertyName, !prop.isEmpty else { return false } + return rtlVariantValue(propertyName: prop) == "On" + } + + /// Determines RTL support: variant property (primary), then description (fallback). + func useRTL(rtlProperty: String? = nil) -> Bool { + // 1. Variant-based detection (primary) + if let prop = rtlProperty, !prop.isEmpty, rtlVariantValue(propertyName: prop) != nil { + return true // Presence of RTL variant = icon supports RTL + } + // 2. Fallback: description guard let description, !description.isEmpty else { return false } return description.localizedCaseInsensitiveContains("rtl") } diff --git a/Sources/ExFigCLI/Loaders/ImagesLoader.swift b/Sources/ExFigCLI/Loaders/ImagesLoader.swift index 0f5a60ce..3b056e68 100644 --- a/Sources/ExFigCLI/Loaders/ImagesLoader.swift +++ b/Sources/ExFigCLI/Loaders/ImagesLoader.swift @@ -41,6 +41,9 @@ struct ImagesLoaderConfig: Sendable { /// When .svg, downloads SVG and rasterizes locally with resvg. let sourceFormat: ImagesSourceFormat + /// Figma component property name for RTL variant detection. + let rtlProperty: String? + /// Creates config for a specific iOS images entry. static func forIOS(entry: iOSImagesEntry, params: PKLConfig) -> ImagesLoaderConfig { ImagesLoaderConfig( @@ -48,7 +51,8 @@ struct ImagesLoaderConfig: Sendable { frameName: entry.figmaFrameName ?? params.common?.images?.figmaFrameName ?? "Illustrations", scales: entry.scales, format: nil, // iOS always uses PNG output - sourceFormat: convertSourceFormat(entry.sourceFormat) + sourceFormat: convertSourceFormat(entry.sourceFormat), + rtlProperty: entry.rtlProperty ) } @@ -59,7 +63,8 @@ struct ImagesLoaderConfig: Sendable { frameName: entry.figmaFrameName ?? params.common?.images?.figmaFrameName ?? "Illustrations", scales: entry.scales, format: convertAndroidFormat(entry.format), - sourceFormat: convertSourceFormat(entry.sourceFormat) + sourceFormat: convertSourceFormat(entry.sourceFormat), + rtlProperty: entry.rtlProperty ) } @@ -70,7 +75,8 @@ struct ImagesLoaderConfig: Sendable { frameName: entry.figmaFrameName ?? params.common?.images?.figmaFrameName ?? "Illustrations", scales: entry.scales, format: entry.format.flatMap { convertFlutterFormat($0) }, - sourceFormat: convertSourceFormat(entry.sourceFormat) + sourceFormat: convertSourceFormat(entry.sourceFormat), + rtlProperty: entry.rtlProperty ) } @@ -81,7 +87,8 @@ struct ImagesLoaderConfig: Sendable { frameName: entry.figmaFrameName ?? params.common?.images?.figmaFrameName ?? "Illustrations", scales: nil, format: .svg, // Web uses SVG by default - sourceFormat: .svg // Web always uses SVG source + sourceFormat: .svg, // Web always uses SVG source + rtlProperty: entry.rtlProperty ) } @@ -92,7 +99,8 @@ struct ImagesLoaderConfig: Sendable { frameName: params.common?.images?.figmaFrameName ?? "Illustrations", scales: nil, format: nil, - sourceFormat: .png + sourceFormat: .png, + rtlProperty: Component.defaultRTLProperty ) } @@ -251,6 +259,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di frameName: frameName, filter: filter, scales: scales, + rtlProperty: config.rtlProperty, onBatchProgress: onBatchProgress ) let (lightImages, darkImages) = splitByDarkMode(images, darkSuffix: darkSuffix) @@ -263,6 +272,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di frameName: frameName, params: SVGParams(), filter: filter, + rtlProperty: config.rtlProperty, onBatchProgress: onBatchProgress ) let (lightPack, darkPack) = splitByDarkMode(pack, darkSuffix: darkSuffix) @@ -318,6 +328,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di frameName: self.frameName, filter: filter, scales: scales, + rtlProperty: self.config.rtlProperty, onBatchProgress: onBatchProgress ) return (key, images) @@ -354,6 +365,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di frameName: self.frameName, params: SVGParams(), filter: filter, + rtlProperty: self.config.rtlProperty, onBatchProgress: onBatchProgress ) return (key, packs) @@ -392,6 +404,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di frameName: frameName, filter: filter, scales: scales, + rtlProperty: config.rtlProperty, onBatchProgress: onBatchProgress ) @@ -423,6 +436,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di frameName: frameName, params: SVGParams(), filter: filter, + rtlProperty: config.rtlProperty, onBatchProgress: onBatchProgress ) @@ -489,6 +503,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di frameName: self.frameName, filter: filter, scales: scales, + rtlProperty: self.config.rtlProperty, onBatchProgress: onBatchProgress ) return FileGranularResult( @@ -506,6 +521,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di frameName: self.frameName, params: SVGParams(), filter: filter, + rtlProperty: self.config.rtlProperty, onBatchProgress: onBatchProgress ) return FileGranularResult( diff --git a/Sources/ExFigCLI/Resources/Schemas/Common.pkl b/Sources/ExFigCLI/Resources/Schemas/Common.pkl index b1f0a496..8ae6026d 100644 --- a/Sources/ExFigCLI/Resources/Schemas/Common.pkl +++ b/Sources/ExFigCLI/Resources/Schemas/Common.pkl @@ -75,6 +75,14 @@ open class FrameSource extends NameProcessing { /// Override Figma file ID for this specific entry. /// When set, overrides the global `figma.lightFileId` for loading data. figmaFileId: String? + + /// Figma component property name for RTL variant detection. + /// When set, components in a COMPONENT_SET with this variant property + /// have their RTL=Off variant exported with RTL metadata (isRTL flag). + /// RTL=On variants are automatically skipped — the base variant is + /// mirrored at runtime by the platform (iOS languageDirection, Android autoMirrored). + /// Set to null to disable variant-based RTL detection. + rtlProperty: String? = "RTL" } // MARK: - Common Settings diff --git a/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl b/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl index cb035143..ee0a4bc5 100644 --- a/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl +++ b/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl @@ -47,6 +47,7 @@ ios = new iOS.iOSConfig { nameStyle = "camelCase" xcassetsPath = "BrandKit/Assets.xcassets" templatesPath = "BrandKit/Templates" + // rtlProperty = "RTL" // default; set to null to disable variant-based RTL detection } } } diff --git a/Sources/ExFigConfig/Generated/Android.pkl.swift b/Sources/ExFigConfig/Generated/Android.pkl.swift index c44b5d1f..7d52db63 100644 --- a/Sources/ExFigConfig/Generated/Android.pkl.swift +++ b/Sources/ExFigConfig/Generated/Android.pkl.swift @@ -259,6 +259,12 @@ extension Android { /// When set, overrides the global `figma.lightFileId` for loading data. public var figmaFileId: String? + /// Figma component property name for RTL variant detection. + /// When set, components with this variant property are marked as RTL. + /// RTL=On variants are automatically skipped (iOS/Android mirror automatically). + /// Set to null to disable variant-based RTL detection. + public var rtlProperty: String? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -277,6 +283,7 @@ extension Android { strictPathValidation: Bool?, figmaFrameName: String?, figmaFileId: String?, + rtlProperty: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -291,6 +298,7 @@ extension Android { self.strictPathValidation = strictPathValidation self.figmaFrameName = figmaFrameName self.figmaFileId = figmaFileId + self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } @@ -333,6 +341,12 @@ extension Android { /// When set, overrides the global `figma.lightFileId` for loading data. public var figmaFileId: String? + /// Figma component property name for RTL variant detection. + /// When set, components with this variant property are marked as RTL. + /// RTL=On variants are automatically skipped (iOS/Android mirror automatically). + /// Set to null to disable variant-based RTL detection. + public var rtlProperty: String? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -350,6 +364,7 @@ extension Android { nameStyle: Common.NameStyle?, figmaFrameName: String?, figmaFileId: String?, + rtlProperty: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -363,6 +378,7 @@ extension Android { self.nameStyle = nameStyle self.figmaFrameName = figmaFrameName self.figmaFileId = figmaFileId + self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigConfig/Generated/Common.pkl.swift b/Sources/ExFigConfig/Generated/Common.pkl.swift index d20c1415..ead78a87 100644 --- a/Sources/ExFigConfig/Generated/Common.pkl.swift +++ b/Sources/ExFigConfig/Generated/Common.pkl.swift @@ -29,6 +29,8 @@ public protocol Common_FrameSource: Common_NameProcessing { var figmaFrameName: String? { get } var figmaFileId: String? { get } + + var rtlProperty: String? { get } } extension Common { @@ -169,6 +171,12 @@ extension Common { /// When set, overrides the global `figma.lightFileId` for loading data. public var figmaFileId: String? + /// Figma component property name for RTL variant detection. + /// When set, components with this variant property are marked as RTL. + /// RTL=On variants are automatically skipped (iOS/Android mirror automatically). + /// Set to null to disable variant-based RTL detection. + public var rtlProperty: String? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -178,11 +186,13 @@ extension Common { public init( figmaFrameName: String?, figmaFileId: String?, + rtlProperty: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { self.figmaFrameName = figmaFrameName self.figmaFileId = figmaFileId + self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigConfig/Generated/Flutter.pkl.swift b/Sources/ExFigConfig/Generated/Flutter.pkl.swift index b303c4ef..31e9416d 100644 --- a/Sources/ExFigConfig/Generated/Flutter.pkl.swift +++ b/Sources/ExFigConfig/Generated/Flutter.pkl.swift @@ -115,6 +115,12 @@ extension Flutter { /// When set, overrides the global `figma.lightFileId` for loading data. public var figmaFileId: String? + /// Figma component property name for RTL variant detection. + /// When set, components with this variant property are marked as RTL. + /// RTL=On variants are automatically skipped (iOS/Android mirror automatically). + /// Set to null to disable variant-based RTL detection. + public var rtlProperty: String? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -129,6 +135,7 @@ extension Flutter { nameStyle: Common.NameStyle?, figmaFrameName: String?, figmaFileId: String?, + rtlProperty: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -139,6 +146,7 @@ extension Flutter { self.nameStyle = nameStyle self.figmaFrameName = figmaFrameName self.figmaFileId = figmaFileId + self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } @@ -183,6 +191,12 @@ extension Flutter { /// When set, overrides the global `figma.lightFileId` for loading data. public var figmaFileId: String? + /// Figma component property name for RTL variant detection. + /// When set, components with this variant property are marked as RTL. + /// RTL=On variants are automatically skipped (iOS/Android mirror automatically). + /// Set to null to disable variant-based RTL detection. + public var rtlProperty: String? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -201,6 +215,7 @@ extension Flutter { nameStyle: Common.NameStyle?, figmaFrameName: String?, figmaFileId: String?, + rtlProperty: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -215,6 +230,7 @@ extension Flutter { self.nameStyle = nameStyle self.figmaFrameName = figmaFrameName self.figmaFileId = figmaFileId + self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigConfig/Generated/Web.pkl.swift b/Sources/ExFigConfig/Generated/Web.pkl.swift index a554e4f0..e7bfca33 100644 --- a/Sources/ExFigConfig/Generated/Web.pkl.swift +++ b/Sources/ExFigConfig/Generated/Web.pkl.swift @@ -127,6 +127,12 @@ extension Web { /// When set, overrides the global `figma.lightFileId` for loading data. public var figmaFileId: String? + /// Figma component property name for RTL variant detection. + /// When set, components with this variant property are marked as RTL. + /// RTL=On variants are automatically skipped (iOS/Android mirror automatically). + /// Set to null to disable variant-based RTL detection. + public var rtlProperty: String? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -142,6 +148,7 @@ extension Web { nameStyle: Common.NameStyle?, figmaFrameName: String?, figmaFileId: String?, + rtlProperty: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -153,6 +160,7 @@ extension Web { self.nameStyle = nameStyle self.figmaFrameName = figmaFrameName self.figmaFileId = figmaFileId + self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } @@ -185,6 +193,12 @@ extension Web { /// When set, overrides the global `figma.lightFileId` for loading data. public var figmaFileId: String? + /// Figma component property name for RTL variant detection. + /// When set, components with this variant property are marked as RTL. + /// RTL=On variants are automatically skipped (iOS/Android mirror automatically). + /// Set to null to disable variant-based RTL detection. + public var rtlProperty: String? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -199,6 +213,7 @@ extension Web { nameStyle: Common.NameStyle?, figmaFrameName: String?, figmaFileId: String?, + rtlProperty: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -209,6 +224,7 @@ extension Web { self.nameStyle = nameStyle self.figmaFrameName = figmaFrameName self.figmaFileId = figmaFileId + self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigConfig/Generated/iOS.pkl.swift b/Sources/ExFigConfig/Generated/iOS.pkl.swift index af9af39e..ab05c4fd 100644 --- a/Sources/ExFigConfig/Generated/iOS.pkl.swift +++ b/Sources/ExFigConfig/Generated/iOS.pkl.swift @@ -208,6 +208,12 @@ extension iOS { /// When set, overrides the global `figma.lightFileId` for loading data. public var figmaFileId: String? + /// Figma component property name for RTL variant detection. + /// When set, components with this variant property are marked as RTL. + /// RTL=On variants are automatically skipped (iOS/Android mirror automatically). + /// Set to null to disable variant-based RTL detection. + public var rtlProperty: String? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -230,6 +236,7 @@ extension iOS { renderModeTemplateSuffix: String?, figmaFrameName: String?, figmaFileId: String?, + rtlProperty: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -248,6 +255,7 @@ extension iOS { self.renderModeTemplateSuffix = renderModeTemplateSuffix self.figmaFrameName = figmaFrameName self.figmaFileId = figmaFileId + self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } @@ -311,6 +319,12 @@ extension iOS { /// When set, overrides the global `figma.lightFileId` for loading data. public var figmaFileId: String? + /// Figma component property name for RTL variant detection. + /// When set, components with this variant property are marked as RTL. + /// RTL=On variants are automatically skipped (iOS/Android mirror automatically). + /// Set to null to disable variant-based RTL detection. + public var rtlProperty: String? + /// Regex pattern for validating/capturing names. public var nameValidateRegexp: String? @@ -335,6 +349,7 @@ extension iOS { renderModeTemplateSuffix: String?, figmaFrameName: String?, figmaFileId: String?, + rtlProperty: String?, nameValidateRegexp: String?, nameReplaceRegexp: String? ) { @@ -355,6 +370,7 @@ extension iOS { self.renderModeTemplateSuffix = renderModeTemplateSuffix self.figmaFrameName = figmaFrameName self.figmaFileId = figmaFileId + self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigCore/Protocol/IconsExportContext.swift b/Sources/ExFigCore/Protocol/IconsExportContext.swift index 7c3964d9..2a535a41 100644 --- a/Sources/ExFigCore/Protocol/IconsExportContext.swift +++ b/Sources/ExFigCore/Protocol/IconsExportContext.swift @@ -84,6 +84,10 @@ public struct IconsSourceInput: Sendable { public let renderModeOriginalSuffix: String? public let renderModeTemplateSuffix: String? + /// Figma component property name for RTL variant detection. + /// Default: `"RTL"`. Set to `nil` to disable variant-based RTL detection. + public let rtlProperty: String? + /// Name validation regex. public let nameValidateRegexp: String? @@ -101,6 +105,7 @@ public struct IconsSourceInput: Sendable { renderModeDefaultSuffix: String? = nil, renderModeOriginalSuffix: String? = nil, renderModeTemplateSuffix: String? = nil, + rtlProperty: String? = "RTL", nameValidateRegexp: String? = nil, nameReplaceRegexp: String? = nil ) { @@ -114,6 +119,7 @@ public struct IconsSourceInput: Sendable { self.renderModeDefaultSuffix = renderModeDefaultSuffix self.renderModeOriginalSuffix = renderModeOriginalSuffix self.renderModeTemplateSuffix = renderModeTemplateSuffix + self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/ExFigCore/Protocol/ImagesExportContext.swift b/Sources/ExFigCore/Protocol/ImagesExportContext.swift index 048f8696..201bd3b3 100644 --- a/Sources/ExFigCore/Protocol/ImagesExportContext.swift +++ b/Sources/ExFigCore/Protocol/ImagesExportContext.swift @@ -183,6 +183,10 @@ public struct ImagesSourceInput: Sendable { /// Suffix for dark mode images when using single file. public let darkModeSuffix: String + /// Figma component property name for RTL variant detection. + /// Default: `"RTL"`. Set to `nil` to disable variant-based RTL detection. + public let rtlProperty: String? + /// Name validation regex. public let nameValidateRegexp: String? @@ -197,6 +201,7 @@ public struct ImagesSourceInput: Sendable { scales: [Double] = [1.0, 2.0, 3.0], useSingleFile: Bool = false, darkModeSuffix: String = "_dark", + rtlProperty: String? = "RTL", nameValidateRegexp: String? = nil, nameReplaceRegexp: String? = nil ) { @@ -207,6 +212,7 @@ public struct ImagesSourceInput: Sendable { self.scales = scales self.useSingleFile = useSingleFile self.darkModeSuffix = darkModeSuffix + self.rtlProperty = rtlProperty self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp } diff --git a/Sources/FigmaAPI/Endpoint/ComponentsEndpoint.swift b/Sources/FigmaAPI/Endpoint/ComponentsEndpoint.swift index 81da49d7..8cd95d9b 100644 --- a/Sources/FigmaAPI/Endpoint/ComponentsEndpoint.swift +++ b/Sources/FigmaAPI/Endpoint/ComponentsEndpoint.swift @@ -59,4 +59,14 @@ public struct ContainingFrame: Codable, Sendable { public let pageId: String? public let pageName: String? public let backgroundColor: String? + public let containingComponentSet: ContainingComponentSet? +} + +// MARK: - ContainingComponentSet + +/// Represents the parent COMPONENT_SET for variant components. +/// Present when a component is a variant inside a component set. +public struct ContainingComponentSet: Codable, Sendable { + public let nodeId: String? + public let name: String? } diff --git a/Tests/ExFigTests/Input/EnumBridgingTests.swift b/Tests/ExFigTests/Input/EnumBridgingTests.swift index feb1be8b..6148e489 100644 --- a/Tests/ExFigTests/Input/EnumBridgingTests.swift +++ b/Tests/ExFigTests/Input/EnumBridgingTests.swift @@ -112,6 +112,7 @@ final class EnumBridgingTests: XCTestCase { renderModeTemplateSuffix: nil, figmaFrameName: nil, figmaFileId: nil, + rtlProperty: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -153,6 +154,7 @@ final class EnumBridgingTests: XCTestCase { renderModeTemplateSuffix: nil, figmaFrameName: nil, figmaFileId: nil, + rtlProperty: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -188,6 +190,7 @@ final class EnumBridgingTests: XCTestCase { strictPathValidation: nil, figmaFrameName: nil, figmaFileId: nil, + rtlProperty: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -211,6 +214,7 @@ final class EnumBridgingTests: XCTestCase { strictPathValidation: nil, figmaFrameName: nil, figmaFileId: nil, + rtlProperty: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -241,6 +245,7 @@ final class EnumBridgingTests: XCTestCase { nameStyle: pklStyle, figmaFrameName: nil, figmaFileId: nil, + rtlProperty: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -263,6 +268,7 @@ final class EnumBridgingTests: XCTestCase { nameStyle: nil, figmaFrameName: nil, figmaFileId: nil, + rtlProperty: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -323,6 +329,7 @@ final class EnumBridgingTests: XCTestCase { renderModeTemplateSuffix: nil, figmaFrameName: nil, figmaFileId: nil, + rtlProperty: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -353,6 +360,7 @@ final class EnumBridgingTests: XCTestCase { renderModeTemplateSuffix: nil, figmaFrameName: nil, figmaFileId: nil, + rtlProperty: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) diff --git a/Tests/ExFigTests/Loaders/ComponentRTLTests.swift b/Tests/ExFigTests/Loaders/ComponentRTLTests.swift new file mode 100644 index 00000000..73d16645 --- /dev/null +++ b/Tests/ExFigTests/Loaders/ComponentRTLTests.swift @@ -0,0 +1,198 @@ +@testable import ExFigCLI +import FigmaAPI +import XCTest + +// MARK: - Component RTL Detection Tests + +final class ComponentRTLTests: XCTestCase { + // MARK: - iconName + + func testIconName_regularComponent_usesOwnName() { + let component = makeComponent(name: "arrow-left") + XCTAssertEqual(component.iconName, "arrow-left") + } + + func testIconName_variantComponent_usesComponentSetName() { + let component = makeComponent( + name: "RTL=Off", + componentSetName: "arrow-left" + ) + XCTAssertEqual(component.iconName, "arrow-left") + } + + func testIconName_variantComponentWithMultipleProperties() { + let component = makeComponent( + name: "State=Default, RTL=Off", + componentSetName: "new-orders" + ) + XCTAssertEqual(component.iconName, "new-orders") + } + + // MARK: - rtlVariantValue + + func testRTLVariantValue_simpleOff() { + let component = makeComponent(name: "RTL=Off") + XCTAssertEqual(component.rtlVariantValue(propertyName: "RTL"), "Off") + } + + func testRTLVariantValue_simpleOn() { + let component = makeComponent(name: "RTL=On") + XCTAssertEqual(component.rtlVariantValue(propertyName: "RTL"), "On") + } + + func testRTLVariantValue_multipleProperties() { + let component = makeComponent(name: "State=Default, RTL=Off") + XCTAssertEqual(component.rtlVariantValue(propertyName: "RTL"), "Off") + XCTAssertEqual(component.rtlVariantValue(propertyName: "State"), "Default") + } + + func testRTLVariantValue_noRTLProperty() { + let component = makeComponent(name: "arrow-left") + XCTAssertNil(component.rtlVariantValue(propertyName: "RTL")) + } + + func testRTLVariantValue_customPropertyName() { + let component = makeComponent(name: "Direction=RTL") + XCTAssertEqual(component.rtlVariantValue(propertyName: "Direction"), "RTL") + XCTAssertNil(component.rtlVariantValue(propertyName: "RTL")) + } + + func testRTLVariantValue_whitespaceAroundEquals() { + let component = makeComponent(name: "RTL = Off") + XCTAssertEqual(component.rtlVariantValue(propertyName: "RTL"), "Off") + } + + func testRTLVariantValue_whitespaceInMultipleProperties() { + let component = makeComponent(name: "State = Default , RTL = On") + XCTAssertEqual(component.rtlVariantValue(propertyName: "RTL"), "On") + XCTAssertEqual(component.rtlVariantValue(propertyName: "State"), "Default") + } + + // MARK: - shouldSkipAsRTLVariant + + func testShouldSkip_RTLOnVariant() { + let component = makeComponent(name: "RTL=On") + XCTAssertTrue(component.shouldSkipAsRTLVariant(propertyName: "RTL")) + } + + func testShouldNotSkip_RTLOffVariant() { + let component = makeComponent(name: "RTL=Off") + XCTAssertFalse(component.shouldSkipAsRTLVariant(propertyName: "RTL")) + } + + func testShouldNotSkip_regularComponent() { + let component = makeComponent(name: "arrow-left") + XCTAssertFalse(component.shouldSkipAsRTLVariant(propertyName: "RTL")) + } + + func testShouldNotSkip_nilPropertyName() { + let component = makeComponent(name: "RTL=On") + XCTAssertFalse(component.shouldSkipAsRTLVariant(propertyName: nil)) + } + + func testShouldNotSkip_emptyPropertyName() { + let component = makeComponent(name: "RTL=On") + XCTAssertFalse(component.shouldSkipAsRTLVariant(propertyName: "")) + } + + func testShouldSkip_RTLOnInMultipleProperties() { + let component = makeComponent(name: "State=Default, RTL=On") + XCTAssertTrue(component.shouldSkipAsRTLVariant(propertyName: "RTL")) + } + + // MARK: - useRTL + + func testUseRTL_variantPropertyPresent_returnsTrue() { + let component = makeComponent( + name: "RTL=Off", + componentSetName: "arrow-left" + ) + XCTAssertTrue(component.useRTL(rtlProperty: "RTL")) + } + + func testUseRTL_variantPropertyNotPresent_fallsBackToDescription() { + let component = makeComponent(name: "arrow-left", description: "rtl icon") + XCTAssertTrue(component.useRTL(rtlProperty: "RTL")) + } + + func testUseRTL_noVariantNoDescription_returnsFalse() { + let component = makeComponent(name: "arrow-left") + XCTAssertFalse(component.useRTL(rtlProperty: "RTL")) + } + + func testUseRTL_nilRtlProperty_fallsBackToDescription() { + let component = makeComponent(name: "arrow-left", description: "RTL support") + XCTAssertTrue(component.useRTL(rtlProperty: nil)) + } + + func testUseRTL_nilRtlProperty_noDescription_returnsFalse() { + let component = makeComponent(name: "arrow-left") + XCTAssertFalse(component.useRTL(rtlProperty: nil)) + } + + func testUseRTL_variantOverridesDescription() { + // Even if description doesn't mention RTL, variant property wins + let component = makeComponent( + name: "RTL=Off", + description: "no mention of direction", + componentSetName: "arrow-left" + ) + XCTAssertTrue(component.useRTL(rtlProperty: "RTL")) + } + + func testUseRTL_descriptionCaseInsensitive() { + let component = makeComponent(name: "arrow-left", description: "This is an RTL icon") + XCTAssertTrue(component.useRTL(rtlProperty: nil)) + } + + func testUseRTL_emptyStringFallsBackToDescription() { + // Empty rtlProperty should behave like nil — fall back to description + let component = makeComponent(name: "arrow-left", description: "rtl icon") + XCTAssertTrue(component.useRTL(rtlProperty: "")) + } + + func testUseRTL_emptyStringNoDescription_returnsFalse() { + let component = makeComponent(name: "arrow-left") + XCTAssertFalse(component.useRTL(rtlProperty: "")) + } + + // MARK: - defaultRTLProperty + + func testDefaultRTLProperty() { + XCTAssertEqual(Component.defaultRTLProperty, "RTL") + } + + // MARK: - Helpers + + private func makeComponent( + name: String, + description: String? = nil, + frameName: String = "Icons", + componentSetName: String? = nil + ) -> Component { + let descriptionField = description.map { ", \"description\": \"\($0)\"" } ?? "" + + let componentSetField = if let componentSetName { + """ + , "containingComponentSet": { "nodeId": "99:0", "name": "\(componentSetName)" } + """ + } else { + "" + } + + let json = """ + { + "key": "test-key", + "node_id": "1:0", + "name": "\(name)"\(descriptionField), + "containing_frame": { + "nodeId": "2:0", + "name": "\(frameName)"\(componentSetField) + } + } + """ + + // swiftlint:disable:next force_try + return try! JSONDecoder().decode(Component.self, from: Data(json.utf8)) + } +} diff --git a/Tests/ExFigTests/Loaders/IconsLoaderConfigTests.swift b/Tests/ExFigTests/Loaders/IconsLoaderConfigTests.swift index 6f0ea8a2..b24e8ef4 100644 --- a/Tests/ExFigTests/Loaders/IconsLoaderConfigTests.swift +++ b/Tests/ExFigTests/Loaders/IconsLoaderConfigTests.swift @@ -197,6 +197,14 @@ final class IconsLoaderConfigTests: XCTestCase { XCTAssertNil(config.renderModeTemplateSuffix) } + func testDefaultConfig_hasDefaultRTLProperty() { + let params = PKLConfig.make(lightFileId: "test") + + let config = IconsLoaderConfig.defaultConfig(params: params) + + XCTAssertEqual(config.rtlProperty, "RTL") + } + // MARK: - Regression: SVG format must not be mapped to nil /// Regression test: when IconsLoaderConfig is constructed with .svg format, @@ -210,7 +218,8 @@ final class IconsLoaderConfigTests: XCTestCase { renderMode: nil, renderModeDefaultSuffix: nil, renderModeOriginalSuffix: nil, - renderModeTemplateSuffix: nil + renderModeTemplateSuffix: nil, + rtlProperty: nil ) XCTAssertEqual(config.format, .svg) @@ -224,7 +233,8 @@ final class IconsLoaderConfigTests: XCTestCase { renderMode: nil, renderModeDefaultSuffix: nil, renderModeOriginalSuffix: nil, - renderModeTemplateSuffix: nil + renderModeTemplateSuffix: nil, + rtlProperty: nil ) XCTAssertEqual(config.format, .pdf) @@ -247,7 +257,8 @@ final class IconsLoaderConfigTests: XCTestCase { renderMode: source.renderMode, renderModeDefaultSuffix: source.renderModeDefaultSuffix, renderModeOriginalSuffix: source.renderModeOriginalSuffix, - renderModeTemplateSuffix: source.renderModeTemplateSuffix + renderModeTemplateSuffix: source.renderModeTemplateSuffix, + rtlProperty: source.rtlProperty ) XCTAssertEqual(config.format, .svg, "SVG format must survive source → config conversion") } @@ -265,11 +276,49 @@ final class IconsLoaderConfigTests: XCTestCase { renderMode: source.renderMode, renderModeDefaultSuffix: source.renderModeDefaultSuffix, renderModeOriginalSuffix: source.renderModeOriginalSuffix, - renderModeTemplateSuffix: source.renderModeTemplateSuffix + renderModeTemplateSuffix: source.renderModeTemplateSuffix, + rtlProperty: source.rtlProperty ) XCTAssertEqual(config.format, .pdf) } + // MARK: - RTL Property Passthrough + + func testRTLPropertyPreservedThroughEntryToSourceToConfig() throws { + let entry = try makeIOSEntry(rtlProperty: "RTL") + + // Step 1: entry → IconsSourceInput + let source = entry.iconsSourceInput() + XCTAssertEqual(source.rtlProperty, "RTL", "rtlProperty must survive entry → source conversion") + + // Step 2: source → IconsLoaderConfig + let config = IconsLoaderConfig( + entryFileId: source.figmaFileId, + frameName: source.frameName, + format: source.format, + renderMode: source.renderMode, + renderModeDefaultSuffix: source.renderModeDefaultSuffix, + renderModeOriginalSuffix: source.renderModeOriginalSuffix, + renderModeTemplateSuffix: source.renderModeTemplateSuffix, + rtlProperty: source.rtlProperty + ) + XCTAssertEqual(config.rtlProperty, "RTL", "rtlProperty must survive source → config conversion") + } + + func testRTLPropertyNilPreservedThroughEntryToSource() throws { + let entry = try makeIOSEntry(rtlProperty: nil) + + let source = entry.iconsSourceInput() + XCTAssertNil(source.rtlProperty, "nil rtlProperty must be preserved through entry → source") + } + + func testRTLPropertyCustomNamePreservedThroughEntryToSource() throws { + let entry = try makeIOSEntry(rtlProperty: "IsRTL") + + let source = entry.iconsSourceInput() + XCTAssertEqual(source.rtlProperty, "IsRTL", "Custom rtlProperty name must be preserved") + } + // MARK: - Helpers private func makeIOSEntry( @@ -280,7 +329,8 @@ final class IconsLoaderConfigTests: XCTestCase { renderMode: String? = nil, renderModeDefaultSuffix: String? = nil, renderModeOriginalSuffix: String? = nil, - renderModeTemplateSuffix: String? = nil + renderModeTemplateSuffix: String? = nil, + rtlProperty: String? = nil ) throws -> iOSIconsEntry { var json = """ { @@ -304,6 +354,9 @@ final class IconsLoaderConfigTests: XCTestCase { if let renderModeTemplateSuffix { json += ", \"renderModeTemplateSuffix\": \"\(renderModeTemplateSuffix)\"" } + if let rtlProperty { + json += ", \"rtlProperty\": \"\(rtlProperty)\"" + } json += "}" return try JSONDecoder().decode(iOSIconsEntry.self, from: Data(json.utf8)) diff --git a/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift b/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift index f9cc2bf2..5568cde4 100644 --- a/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift +++ b/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift @@ -236,13 +236,59 @@ final class ImagesLoaderConfigTests: XCTestCase { XCTAssertNil(config.format) } + // MARK: - Default Config RTL Property + + func testDefaultConfig_hasDefaultRTLProperty() { + let params = PKLConfig.make(lightFileId: "test") + + let config = ImagesLoaderConfig.defaultConfig(params: params) + + XCTAssertEqual(config.rtlProperty, "RTL") + } + + // MARK: - RTL Property Passthrough + + func testRTLPropertyPreservedThroughEntryToSourceToConfig() throws { + let entry = try makeIOSEntry(rtlProperty: "RTL") + + // Step 1: entry → ImagesSourceInput + let source = entry.imagesSourceInput() + XCTAssertEqual(source.rtlProperty, "RTL", "rtlProperty must survive entry → source conversion") + + // Step 2: source → ImagesLoaderConfig + let config = ImagesLoaderConfig( + entryFileId: source.figmaFileId, + frameName: source.frameName, + scales: source.scales, + format: nil, + sourceFormat: .png, + rtlProperty: source.rtlProperty + ) + XCTAssertEqual(config.rtlProperty, "RTL", "rtlProperty must survive source → config conversion") + } + + func testRTLPropertyNilPreservedThroughEntryToSource() throws { + let entry = try makeIOSEntry(rtlProperty: nil) + + let source = entry.imagesSourceInput() + XCTAssertNil(source.rtlProperty, "nil rtlProperty must be preserved through entry → source") + } + + func testRTLPropertyCustomNamePreservedThroughEntryToSource() throws { + let entry = try makeIOSEntry(rtlProperty: "IsRTL") + + let source = entry.imagesSourceInput() + XCTAssertEqual(source.rtlProperty, "IsRTL", "Custom rtlProperty name must be preserved") + } + // MARK: - Helpers private func makeIOSEntry( figmaFrameName: String? = nil, assetsFolder: String = "Images", nameStyle: String = "camelCase", - scales: [Double]? = nil + scales: [Double]? = nil, + rtlProperty: String? = nil ) throws -> iOSImagesEntry { var json = """ { @@ -257,6 +303,9 @@ final class ImagesLoaderConfigTests: XCTestCase { let scalesJson = scales.map { String($0) }.joined(separator: ", ") json += ", \"scales\": [\(scalesJson)]" } + if let rtlProperty { + json += ", \"rtlProperty\": \"\(rtlProperty)\"" + } json += "}" return try JSONDecoder().decode(iOSImagesEntry.self, from: Data(json.utf8))