diff --git a/.claude/rules/lint-patterns.md b/.claude/rules/lint-patterns.md index f5efb96a..79de841d 100644 --- a/.claude/rules/lint-patterns.md +++ b/.claude/rules/lint-patterns.md @@ -26,3 +26,4 @@ Uses `FigmaAPI.Client.request(SomeEndpoint(...))` directly (no convenience metho - `LintEngineTests.defaultEngineHasAllRules` checks exact set of rule IDs — must add new rule ID when registering in `LintEngine.default` - `NodesEndpoint` supports `geometry: .paths` parameter — returns `fillGeometry`/`strokeGeometry` with SVG path data on vector nodes. **Not suitable for pathData validation** — Figma's SVG export flattens masks/booleans into different paths than raw geometry - `PathDataLengthRule` checks ALL platform icon entries (iOS/Android/Flutter/Web), deduplicates by fileId+frame+page. Downloads SVGs via `ImageEndpoint` + `URLSession`, parses with `SVGParser`, validates with `PathDataValidator`. Only reports critical >32,767 byte errors (800-char threshold removed as too noisy). Groups by fileId, batches ImageEndpoint by 50, parallelizes SVG downloads (max 10 concurrent) and fileIds +- `InvalidRTLVariantValueRule` validates RTL variant property values against configured `rtlActiveValues` (default `["On"]`) and their known counterpart pairs (On↔Off, true↔false, True↔False, Yes↔No, 0↔1). Uses Components API only (no ImageEndpoint). Collects icon entries from all platforms with `rtlProperty` and `rtlActiveValues`, deduplicates by fileId+frame+page+rtlProperty. `validateRTLValues` and `validValues(for:)` are internal for testability. Suggests either renaming in Figma or adding value to `rtlActiveValues` config diff --git a/.claude/rules/source-patterns.md b/.claude/rules/source-patterns.md index 3eb1ccbd..2be915f3 100644 --- a/.claude/rules/source-patterns.md +++ b/.claude/rules/source-patterns.md @@ -28,6 +28,7 @@ Do NOT inject `colorsSource` at context construction time — it breaks multi-so - `Component.iconName`: uses `containingComponentSet.name` for variants, own `name` otherwise - `Component.codeConnectNodeId`: uses `containingComponentSet.nodeId` for variants, own `nodeId` otherwise (Figma Code Connect rejects variant node IDs) - `Component.defaultRTLProperty = "RTL"`: shared constant in ExFigCLI for the magic string +- `rtlActiveValues: Listing? = new { "On" }`: configurable per-entry list of variant values that mean "active RTL" (skipped during export). `shouldSkipAsRTLVariant(propertyName:activeValues:)` checks against this list. Known pairs (case-sensitive): Off↔On, off↔on, false↔true, False↔True, No↔Yes, no↔yes, 0↔1 - 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 diff --git a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift index a71223a9..88f0c9f6 100644 --- a/Sources/ExFig-Android/Config/AndroidIconsEntry.swift +++ b/Sources/ExFig-Android/Config/AndroidIconsEntry.swift @@ -23,6 +23,7 @@ public extension Android.IconsEntry { useSingleFile: darkFileId == nil && variablesDarkMode == nil, darkModeSuffix: "_dark", rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, penpotBaseURL: resolvedPenpotBaseURL, diff --git a/Sources/ExFig-Android/Config/AndroidImagesEntry.swift b/Sources/ExFig-Android/Config/AndroidImagesEntry.swift index 12a06347..1029eff2 100644 --- a/Sources/ExFig-Android/Config/AndroidImagesEntry.swift +++ b/Sources/ExFig-Android/Config/AndroidImagesEntry.swift @@ -41,6 +41,7 @@ public extension Android.ImagesEntry { useSingleFile: darkFileId == nil, darkModeSuffix: "_dark", rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, penpotBaseURL: resolvedPenpotBaseURL diff --git a/Sources/ExFig-Android/Export/AndroidImagesExporter.swift b/Sources/ExFig-Android/Export/AndroidImagesExporter.swift index 81d4acaa..07f3eef5 100644 --- a/Sources/ExFig-Android/Export/AndroidImagesExporter.swift +++ b/Sources/ExFig-Android/Export/AndroidImagesExporter.swift @@ -446,6 +446,7 @@ private extension AndroidImagesExporter { useSingleFile: true, darkModeSuffix: "_dark", rtlProperty: entry.rtlProperty, + rtlActiveValues: entry.rtlActiveValues, nameValidateRegexp: entry.nameValidateRegexp, nameReplaceRegexp: entry.nameReplaceRegexp ) diff --git a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift index c97975a5..29c64025 100644 --- a/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift +++ b/Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift @@ -19,6 +19,7 @@ public extension Flutter.IconsEntry { useSingleFile: darkFileId == nil && variablesDarkMode == nil, darkModeSuffix: "_dark", rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, penpotBaseURL: resolvedPenpotBaseURL, diff --git a/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift b/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift index c5912a87..83be4ff1 100644 --- a/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift +++ b/Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift @@ -24,6 +24,7 @@ public extension Flutter.ImagesEntry { useSingleFile: darkFileId == nil, darkModeSuffix: "_dark", rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, penpotBaseURL: resolvedPenpotBaseURL @@ -55,6 +56,7 @@ public extension Flutter.ImagesEntry { useSingleFile: darkFileId == nil, darkModeSuffix: "_dark", rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, penpotBaseURL: resolvedPenpotBaseURL diff --git a/Sources/ExFig-Web/Config/WebIconsEntry.swift b/Sources/ExFig-Web/Config/WebIconsEntry.swift index 0fd37917..624b1ca2 100644 --- a/Sources/ExFig-Web/Config/WebIconsEntry.swift +++ b/Sources/ExFig-Web/Config/WebIconsEntry.swift @@ -19,6 +19,7 @@ public extension Web.IconsEntry { useSingleFile: darkFileId == nil && variablesDarkMode == nil, darkModeSuffix: "_dark", rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, penpotBaseURL: resolvedPenpotBaseURL, diff --git a/Sources/ExFig-Web/Config/WebImagesEntry.swift b/Sources/ExFig-Web/Config/WebImagesEntry.swift index d2f2dc1b..61bb8475 100644 --- a/Sources/ExFig-Web/Config/WebImagesEntry.swift +++ b/Sources/ExFig-Web/Config/WebImagesEntry.swift @@ -21,6 +21,7 @@ public extension Web.ImagesEntry { useSingleFile: darkFileId == nil, darkModeSuffix: "_dark", rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, penpotBaseURL: resolvedPenpotBaseURL diff --git a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift index 7cb04627..8f5b2887 100644 --- a/Sources/ExFig-iOS/Config/iOSIconsEntry.swift +++ b/Sources/ExFig-iOS/Config/iOSIconsEntry.swift @@ -26,6 +26,7 @@ public extension iOS.IconsEntry { renderModeOriginalSuffix: renderModeOriginalSuffix, renderModeTemplateSuffix: renderModeTemplateSuffix, rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, penpotBaseURL: resolvedPenpotBaseURL, diff --git a/Sources/ExFig-iOS/Config/iOSImagesEntry.swift b/Sources/ExFig-iOS/Config/iOSImagesEntry.swift index 0ca2f263..a34266f0 100644 --- a/Sources/ExFig-iOS/Config/iOSImagesEntry.swift +++ b/Sources/ExFig-iOS/Config/iOSImagesEntry.swift @@ -23,6 +23,7 @@ public extension iOS.ImagesEntry { useSingleFile: darkFileId == nil, darkModeSuffix: "_dark", rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp, penpotBaseURL: resolvedPenpotBaseURL diff --git a/Sources/ExFig-iOS/Export/iOSImagesExporter.swift b/Sources/ExFig-iOS/Export/iOSImagesExporter.swift index 0d8e67c6..54d607b4 100644 --- a/Sources/ExFig-iOS/Export/iOSImagesExporter.swift +++ b/Sources/ExFig-iOS/Export/iOSImagesExporter.swift @@ -475,6 +475,7 @@ private extension iOSImagesEntry { useSingleFile: true, darkModeSuffix: "_dark", rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues, nameValidateRegexp: nameValidateRegexp, nameReplaceRegexp: nameReplaceRegexp ) diff --git a/Sources/ExFigCLI/Context/IconsExportContextImpl.swift b/Sources/ExFigCLI/Context/IconsExportContextImpl.swift index d0cf0f0a..17b5eccd 100644 --- a/Sources/ExFigCLI/Context/IconsExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/IconsExportContextImpl.swift @@ -166,7 +166,8 @@ struct IconsExportContextImpl: IconsExportContextWithGranularCache { renderModeDefaultSuffix: source.renderModeDefaultSuffix, renderModeOriginalSuffix: source.renderModeOriginalSuffix, renderModeTemplateSuffix: source.renderModeTemplateSuffix, - rtlProperty: source.rtlProperty + rtlProperty: source.rtlProperty, + rtlActiveValues: source.rtlActiveValues ) let loader = IconsLoader( diff --git a/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift b/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift index 775f53db..285c5305 100644 --- a/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/ImagesExportContextImpl.swift @@ -351,7 +351,8 @@ struct ImagesExportContextImpl: ImagesExportContextWithGranularCache { scales: source.scales, format: nil, sourceFormat: loaderSourceFormat, - rtlProperty: source.rtlProperty + rtlProperty: source.rtlProperty, + rtlActiveValues: source.rtlActiveValues ) let loader = ImagesLoader( diff --git a/Sources/ExFigCLI/ExFig.docc/Configuration.md b/Sources/ExFigCLI/ExFig.docc/Configuration.md index bca76d4c..9dd5f1d2 100644 --- a/Sources/ExFigCLI/ExFig.docc/Configuration.md +++ b/Sources/ExFigCLI/ExFig.docc/Configuration.md @@ -214,6 +214,31 @@ common = new Common.CommonConfig { } ``` +### RTL (Right-to-Left) Configuration + +Icons in a COMPONENT_SET can have an RTL variant property (e.g., `RTL=Off` and `RTL=On`). +The "active" variant is skipped during export — platforms mirror the base icon at runtime. + +```pkl +new iOS.IconsEntry { + // Property name in Figma (default: "RTL") + rtlProperty = "RTL" + + // Values meaning "active RTL" — these variants are skipped. + // Default: new { "On" } (paired with "Off") + rtlActiveValues = new { "On" } + + // If your Figma uses true/false instead of On/Off: + // rtlActiveValues = new { "true" } +} +``` + +Set `rtlProperty = null` to disable RTL detection entirely. + +The `exfig lint` rule `invalid-rtl-variant-value` validates that RTL variant values +match the configured `rtlActiveValues` and their known counterparts +(On↔Off, true↔false, True↔False, Yes↔No, 0↔1). + ### Images ```pkl diff --git a/Sources/ExFigCLI/Lint/LintEngine.swift b/Sources/ExFigCLI/Lint/LintEngine.swift index 98d811ad..3e199bf4 100644 --- a/Sources/ExFigCLI/Lint/LintEngine.swift +++ b/Sources/ExFigCLI/Lint/LintEngine.swift @@ -62,5 +62,6 @@ extension LintEngine { DarkModeVariablesRule(), DarkModeSuffixRule(), PathDataLengthRule(), + InvalidRTLVariantValueRule(), ]) } diff --git a/Sources/ExFigCLI/Lint/Rules/InvalidRTLVariantValueRule.swift b/Sources/ExFigCLI/Lint/Rules/InvalidRTLVariantValueRule.swift new file mode 100644 index 00000000..5fa2afbe --- /dev/null +++ b/Sources/ExFigCLI/Lint/Rules/InvalidRTLVariantValueRule.swift @@ -0,0 +1,209 @@ +import ExFigConfig +import ExFigCore +import FigmaAPI +import Foundation + +/// Checks that RTL variant property values match the configured `rtlActiveValues` +/// and their known counterparts. +/// +/// When `rtlActiveValues` is `["On"]` (default), valid values are `Off` and `On`. +/// When set to `["true"]`, valid values are `false` and `true`. +/// This prevents silent incorrect behavior where RTL variants with unrecognized +/// values are exported as regular icons instead of being skipped. +struct InvalidRTLVariantValueRule: LintRule { + let id = "invalid-rtl-variant-value" + let name = "RTL variant property values" + let description = "RTL variant values must match configured rtlActiveValues and their counterparts" + let severity: LintSeverity = .error + + /// Known boolean-like value pairs (inactive, active). + static let knownPairs: [(String, String)] = [ + ("Off", "On"), + ("off", "on"), + ("false", "true"), + ("False", "True"), + ("No", "Yes"), + ("no", "yes"), + ("0", "1"), + ] + + /// Builds the set of valid values from configured active values. + /// For each active value, includes its known counterpart. + static func validValues(for activeValues: [String]) -> Set { + var result = Set(activeValues) + for active in activeValues { + for (inactive, knownActive) in knownPairs { + if knownActive == active { + result.insert(inactive) + } else if inactive == active { + result.insert(knownActive) + } + } + } + return result + } + + func check(context: LintContext) async throws -> [LintDiagnostic] { + let config = context.config + let defaultFileId = config.figma?.lightFileId ?? "" + + let entries = collectEntries(from: config, defaultFileId: defaultFileId) + guard !entries.isEmpty else { return [] } + + let grouped = Dictionary(grouping: entries) { $0.fileId } + + return try await withThrowingTaskGroup(of: [LintDiagnostic].self) { group in + for (fileId, fileEntries) in grouped { + group.addTask { + try await checkFileEntries(fileEntries, fileId: fileId, context: context) + } + } + var allDiagnostics: [LintDiagnostic] = [] + for try await diagnostics in group { + allDiagnostics.append(contentsOf: diagnostics) + } + return allDiagnostics + } + } + + // MARK: - Per-File Check + + private func checkFileEntries( + _ entries: [IconEntry], + fileId: String, + context: LintContext + ) async throws -> [LintDiagnostic] { + guard !fileId.isEmpty else { + return [diagnostic( + message: "No figma.lightFileId configured — skipping RTL variant value check", + suggestion: "Set figma.lightFileId in your PKL config" + )] + } + + let components: [Component] + do { + components = try await context.cache.components(for: fileId, client: context.client) + } catch { + return [diagnostic( + severity: .error, + message: "Cannot fetch components for file '\(fileId)': \(error.localizedDescription)", + suggestion: "Check FIGMA_PERSONAL_TOKEN and file permissions" + )] + } + + return validateRTLValues(components: components, entries: entries) + } + + // MARK: - Validation + + /// Validates RTL variant property values — internal for testability. + func validateRTLValues( + components: [Component], + entries: [IconEntry] + ) -> [LintDiagnostic] { + var diagnostics: [LintDiagnostic] = [] + let entryValidValues = entries.map { Self.validValues(for: $0.rtlActiveValues) } + + for comp in components { + guard comp.containingFrame.containingComponentSet != nil else { continue } + + for (index, entry) in entries.enumerated() { + guard matchesEntry(comp, entry) else { continue } + guard let value = comp.rtlVariantValue(propertyName: entry.rtlProperty) else { continue } + + if !entryValidValues[index].contains(value) { + let iconName = comp.iconName + let expected = entry.rtlActiveValues.sorted().joined(separator: "' or '") + diagnostics.append(diagnostic( + message: "RTL variant '\(iconName) (\(entry.rtlProperty)=\(value))' " + + "uses unrecognized value '\(value)' — " + + "expected values matching rtlActiveValues: '\(expected)' or their counterparts", + componentName: iconName, + nodeId: comp.nodeId, + suggestion: "Either rename '\(entry.rtlProperty)=\(value)' in Figma, " + + "or add '\(value)' to rtlActiveValues in your PKL config" + )) + } + break + } + } + + return diagnostics + } + + // MARK: - Helpers + + private func matchesEntry(_ comp: Component, _ entry: IconEntry) -> Bool { + if let page = entry.pageName, comp.containingFrame.pageName != page { return false } + if let frame = entry.frameName, comp.containingFrame.name != frame { return false } + return true + } + + // MARK: - Types + + struct IconEntry { + let fileId: String + let frameName: String? + let pageName: String? + let rtlProperty: String + let rtlActiveValues: [String] + } + + // MARK: - Entry Collection + + private func collectEntries( + from config: ExFig.ModuleImpl, + defaultFileId: String + ) -> [IconEntry] { + var entries: [IconEntry] = [] + + let commonIconsFrame = config.common?.icons?.figmaFrameName ?? "Icons" + let commonIconsPage = config.common?.icons?.figmaPageName + let commonImagesFrame = config.common?.images?.figmaFrameName ?? "Illustrations" + let commonImagesPage = config.common?.images?.figmaPageName + + func addEntries( + _ sources: [some Common_FrameSource]?, + defaultFrame: String, + defaultPage: String? + ) { + for entry in sources ?? [] { + guard let rtlProperty = entry.rtlProperty, !rtlProperty.isEmpty else { continue } + entries.append(IconEntry( + fileId: entry.figmaFileId ?? defaultFileId, + frameName: entry.figmaFrameName ?? defaultFrame, + pageName: entry.figmaPageName ?? defaultPage, + rtlProperty: rtlProperty, + rtlActiveValues: entry.rtlActiveValues ?? ["On"] + )) + } + } + + if let ios = config.ios { + addEntries(ios.icons, defaultFrame: commonIconsFrame, defaultPage: commonIconsPage) + addEntries(ios.images, defaultFrame: commonImagesFrame, defaultPage: commonImagesPage) + } + if let android = config.android { + addEntries(android.icons, defaultFrame: commonIconsFrame, defaultPage: commonIconsPage) + addEntries(android.images, defaultFrame: commonImagesFrame, defaultPage: commonImagesPage) + } + if let flutter = config.flutter { + addEntries(flutter.icons, defaultFrame: commonIconsFrame, defaultPage: commonIconsPage) + addEntries(flutter.images, defaultFrame: commonImagesFrame, defaultPage: commonImagesPage) + } + if let web = config.web { + addEntries(web.icons, defaultFrame: commonIconsFrame, defaultPage: commonIconsPage) + addEntries(web.images, defaultFrame: commonImagesFrame, defaultPage: commonImagesPage) + } + + var seen = Set() + return entries.filter { entry in + let activeKey = entry.rtlActiveValues.sorted().joined(separator: ",") + let key = [ + entry.fileId, entry.frameName ?? "", entry.pageName ?? "", + entry.rtlProperty, activeKey, + ].joined(separator: "|") + return seen.insert(key).inserted + } + } +} diff --git a/Sources/ExFigCLI/Loaders/IconsLoader.swift b/Sources/ExFigCLI/Loaders/IconsLoader.swift index 354d8a59..8e4e6a12 100644 --- a/Sources/ExFigCLI/Loaders/IconsLoader.swift +++ b/Sources/ExFigCLI/Loaders/IconsLoader.swift @@ -47,6 +47,9 @@ struct IconsLoaderConfig { /// Figma component property name for RTL variant detection. let rtlProperty: String? + /// Values of the RTL variant property that indicate "active" RTL direction. + let rtlActiveValues: [String]? + /// Creates config for a specific iOS icons entry. static func forIOS(entry: iOSIconsEntry, params: PKLConfig) -> IconsLoaderConfig { IconsLoaderConfig( @@ -58,7 +61,8 @@ struct IconsLoaderConfig { renderModeDefaultSuffix: entry.renderModeDefaultSuffix, renderModeOriginalSuffix: entry.renderModeOriginalSuffix, renderModeTemplateSuffix: entry.renderModeTemplateSuffix, - rtlProperty: entry.rtlProperty + rtlProperty: entry.rtlProperty, + rtlActiveValues: entry.rtlActiveValues ) } @@ -73,7 +77,8 @@ struct IconsLoaderConfig { renderModeDefaultSuffix: nil, renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, - rtlProperty: entry.rtlProperty + rtlProperty: entry.rtlProperty, + rtlActiveValues: entry.rtlActiveValues ) } @@ -88,7 +93,8 @@ struct IconsLoaderConfig { renderModeDefaultSuffix: nil, renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, - rtlProperty: entry.rtlProperty + rtlProperty: entry.rtlProperty, + rtlActiveValues: entry.rtlActiveValues ) } @@ -103,7 +109,8 @@ struct IconsLoaderConfig { renderModeDefaultSuffix: nil, renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, - rtlProperty: entry.rtlProperty + rtlProperty: entry.rtlProperty, + rtlActiveValues: entry.rtlActiveValues ) } @@ -118,7 +125,8 @@ struct IconsLoaderConfig { renderModeDefaultSuffix: nil, renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, - rtlProperty: Component.defaultRTLProperty + rtlProperty: Component.defaultRTLProperty, + rtlActiveValues: ["On"] ) } } @@ -194,6 +202,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { params: formatParams, filter: filter, rtlProperty: config.rtlProperty, + rtlActiveValues: config.rtlActiveValues, onBatchProgress: onBatchProgress ) @@ -235,6 +244,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { params: formatParams, filter: filter, rtlProperty: self.config.rtlProperty, + rtlActiveValues: self.config.rtlActiveValues, onBatchProgress: onBatchProgress ).map { self.updateRenderMode($0) } return (key, icons) @@ -309,6 +319,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { filter: filter, darkModeSuffix: darkSuffix, rtlProperty: config.rtlProperty, + rtlActiveValues: config.rtlActiveValues, onBatchProgress: onBatchProgress ) @@ -373,6 +384,7 @@ final class IconsLoader: ImageLoaderBase, @unchecked Sendable { params: formatParams, filter: filter, rtlProperty: self.config.rtlProperty, + rtlActiveValues: self.config.rtlActiveValues, onBatchProgress: onBatchProgress ) diff --git a/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift b/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift index 5aef264b..5a4f4072 100644 --- a/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift +++ b/Sources/ExFigCLI/Loaders/ImageLoaderBase.swift @@ -72,13 +72,14 @@ 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. + /// When `rtlProperty` is set, RTL active variants (matching `rtlActiveValues`) are filtered out. func fetchImageComponents( fileId: String, frameName: String, pageName: String? = nil, filter: String? = nil, - rtlProperty: String? = Component.defaultRTLProperty + rtlProperty: String? = Component.defaultRTLProperty, + rtlActiveValues: [String]? = nil ) async throws -> [NodeId: Component] { let allComponents = try await loadComponents(fileId: fileId) var components = allComponents @@ -99,13 +100,16 @@ class ImageLoaderBase: @unchecked Sendable { } } - // Skip RTL=On variants: the base (RTL=Off) icon is sufficient — + // Skip RTL active variants: the base (inactive) icon is sufficient — // platforms mirror it at runtime (iOS languageDirection, Android autoMirrored). let beforeRTLFilter = components.count - components = components.filter { !$0.shouldSkipAsRTLVariant(propertyName: rtlProperty) } + components = components.filter { + !$0.shouldSkipAsRTLVariant(propertyName: rtlProperty, activeValues: rtlActiveValues) + } let rtlSkipped = beforeRTLFilter - components.count if rtlSkipped > 0 { - logger.info("Filtered out \(rtlSkipped) RTL=On variant(s) from '\(frameName)'") + let activeDesc = (rtlActiveValues ?? ["On"]).joined(separator: "/") + logger.info("Filtered out \(rtlSkipped) RTL=\(activeDesc) variant(s) from '\(frameName)'") } if let filter { @@ -140,14 +144,16 @@ class ImageLoaderBase: @unchecked Sendable { frameName: String, pageName: String? = nil, filter: String? = nil, - rtlProperty: String? = Component.defaultRTLProperty + rtlProperty: String? = Component.defaultRTLProperty, + rtlActiveValues: [String]? = nil ) async throws -> GranularFilterResult { let allComponents = try await fetchImageComponents( fileId: fileId, frameName: frameName, pageName: pageName, filter: filter, - rtlProperty: rtlProperty + rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues ) logger.debug( @@ -215,10 +221,12 @@ class ImageLoaderBase: @unchecked Sendable { pageName: String? = nil, filter: String? = nil, darkModeSuffix: String, - rtlProperty: String? = Component.defaultRTLProperty + rtlProperty: String? = Component.defaultRTLProperty, + rtlActiveValues: [String]? = nil ) async throws -> GranularFilterResult { let allComponents = try await fetchImageComponents( - fileId: fileId, frameName: frameName, pageName: pageName, filter: filter, rtlProperty: rtlProperty + fileId: fileId, frameName: frameName, pageName: pageName, filter: filter, rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues ) let allAssetMetadata = allComponents.map { _, component in AssetMetadata(name: component.iconName, nodeId: component.codeConnectNodeId, fileId: fileId) @@ -298,10 +306,12 @@ class ImageLoaderBase: @unchecked Sendable { params: FormatParams, filter: String? = nil, rtlProperty: String? = Component.defaultRTLProperty, + rtlActiveValues: [String]? = nil, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> [ImagePack] { let imagesDict = try await fetchImageComponents( - fileId: fileId, frameName: frameName, pageName: pageName, filter: filter, rtlProperty: rtlProperty + fileId: fileId, frameName: frameName, pageName: pageName, filter: filter, rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues ) return try await loadVectorImagesFromComponents( fileId: fileId, @@ -309,6 +319,7 @@ class ImageLoaderBase: @unchecked Sendable { components: imagesDict, params: params, rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues, onBatchProgress: onBatchProgress ) } @@ -327,10 +338,12 @@ class ImageLoaderBase: @unchecked Sendable { params: FormatParams, filter: String? = nil, rtlProperty: String? = Component.defaultRTLProperty, + rtlActiveValues: [String]? = nil, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> ImagesWithHashesResult { let filterResult = try await fetchImageComponentsWithGranularCache( - fileId: fileId, frameName: frameName, pageName: pageName, filter: filter, rtlProperty: rtlProperty + fileId: fileId, frameName: frameName, pageName: pageName, filter: filter, rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues ) return try await loadVectorImagesFromGranularFilterResult( fileId: fileId, @@ -338,6 +351,7 @@ class ImageLoaderBase: @unchecked Sendable { filterResult: filterResult, params: params, rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues, onBatchProgress: onBatchProgress ) } @@ -355,6 +369,7 @@ class ImageLoaderBase: @unchecked Sendable { filter: String? = nil, darkModeSuffix: String, rtlProperty: String? = Component.defaultRTLProperty, + rtlActiveValues: [String]? = nil, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> ImagesWithHashesResult { let filterResult = try await fetchImageComponentsWithGranularCacheAndPairing( @@ -363,7 +378,8 @@ class ImageLoaderBase: @unchecked Sendable { pageName: pageName, filter: filter, darkModeSuffix: darkModeSuffix, - rtlProperty: rtlProperty + rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues ) return try await loadVectorImagesFromGranularFilterResult( fileId: fileId, @@ -371,6 +387,7 @@ class ImageLoaderBase: @unchecked Sendable { filterResult: filterResult, params: params, rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues, onBatchProgress: onBatchProgress ) } @@ -384,6 +401,7 @@ class ImageLoaderBase: @unchecked Sendable { components: [NodeId: Component], params: FormatParams, rtlProperty: String? = Component.defaultRTLProperty, + rtlActiveValues: [String]? = nil, onBatchProgress: @escaping BatchProgressCallback ) async throws -> [ImagePack] { var imagesDict = components @@ -409,7 +427,8 @@ class ImageLoaderBase: @unchecked Sendable { imageIdToImagePath: imageIdToImagePath, format: params.format, fileId: fileId, - rtlProperty: rtlProperty + rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues ) } @@ -420,6 +439,7 @@ class ImageLoaderBase: @unchecked Sendable { filterResult: GranularFilterResult, params: FormatParams, rtlProperty: String? = Component.defaultRTLProperty, + rtlActiveValues: [String]? = nil, onBatchProgress: @escaping BatchProgressCallback ) async throws -> ImagesWithHashesResult { if filterResult.allSkipped { @@ -437,6 +457,7 @@ class ImageLoaderBase: @unchecked Sendable { components: filterResult.components, params: params, rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues, onBatchProgress: onBatchProgress ) @@ -486,7 +507,8 @@ class ImageLoaderBase: @unchecked Sendable { imageIdToImagePath: [NodeId: ImagePath], format: String, fileId: String, - rtlProperty: String? = Component.defaultRTLProperty + rtlProperty: String? = Component.defaultRTLProperty, + rtlActiveValues: [String]? = nil ) -> [ImagePack] { let groups = Dictionary(grouping: imagesDict) { $1.iconName.parseNameAndIdiom(platform: platform).name @@ -528,10 +550,12 @@ class ImageLoaderBase: @unchecked Sendable { filter: String? = nil, scales: [Double], rtlProperty: String? = Component.defaultRTLProperty, + rtlActiveValues: [String]? = nil, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> [ImagePack] { let imagesDict = try await fetchImageComponents( - fileId: fileId, frameName: frameName, pageName: pageName, filter: filter, rtlProperty: rtlProperty + fileId: fileId, frameName: frameName, pageName: pageName, filter: filter, rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues ) return try await loadPNGImagesFromComponents( fileId: fileId, @@ -555,10 +579,12 @@ class ImageLoaderBase: @unchecked Sendable { filter: String? = nil, scales: [Double], rtlProperty: String? = Component.defaultRTLProperty, + rtlActiveValues: [String]? = nil, onBatchProgress: @escaping BatchProgressCallback = { _, _ in } ) async throws -> ImagesWithHashesResult { let filterResult = try await fetchImageComponentsWithGranularCache( - fileId: fileId, frameName: frameName, pageName: pageName, filter: filter, rtlProperty: rtlProperty + fileId: fileId, frameName: frameName, pageName: pageName, filter: filter, rtlProperty: rtlProperty, + rtlActiveValues: rtlActiveValues ) if filterResult.allSkipped { @@ -836,10 +862,13 @@ class ImageLoaderBase: @unchecked Sendable { if let imagePath { return (nodeId, imagePath) } else { - let componentName = imagesDict[nodeId]?.name ?? "" + let component = imagesDict[nodeId] + let iconName = component?.iconName ?? "" + let variantName = component?.name ?? "" + let displayName = iconName == variantName ? iconName : "\(iconName) (\(variantName))" let errorMsg = "Unable to get image for node with id = \(nodeId). " - + "Please check that component \(componentName) in the Figma file is not empty. Skipping..." + + "Please check that component \(displayName) in the Figma file is not empty. Skipping..." logger.error("\(errorMsg)") return nil } @@ -927,10 +956,12 @@ public extension Component { /// 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 { + /// Whether this component should be skipped (RTL active variant). + func shouldSkipAsRTLVariant(propertyName: String?, activeValues: [String]? = nil) -> Bool { guard let prop = propertyName, !prop.isEmpty else { return false } - return rtlVariantValue(propertyName: prop) == "On" + guard let value = rtlVariantValue(propertyName: prop) else { return false } + let active = activeValues ?? ["On"] + return active.contains(value) } /// Determines RTL support: variant property (primary), then description (fallback). diff --git a/Sources/ExFigCLI/Loaders/ImagesLoader.swift b/Sources/ExFigCLI/Loaders/ImagesLoader.swift index 53c83205..511c19c9 100644 --- a/Sources/ExFigCLI/Loaders/ImagesLoader.swift +++ b/Sources/ExFigCLI/Loaders/ImagesLoader.swift @@ -47,6 +47,9 @@ struct ImagesLoaderConfig { /// Figma component property name for RTL variant detection. let rtlProperty: String? + /// Values of the RTL variant property that indicate "active" RTL direction. + let rtlActiveValues: [String]? + /// Creates config for a specific iOS images entry. static func forIOS(entry: iOSImagesEntry, params: PKLConfig) -> ImagesLoaderConfig { ImagesLoaderConfig( @@ -56,7 +59,8 @@ struct ImagesLoaderConfig { scales: entry.scales, format: nil, // iOS always uses PNG output sourceFormat: convertSourceFormat(entry.sourceFormat), - rtlProperty: entry.rtlProperty + rtlProperty: entry.rtlProperty, + rtlActiveValues: entry.rtlActiveValues ) } @@ -69,7 +73,8 @@ struct ImagesLoaderConfig { scales: entry.scales, format: convertAndroidFormat(entry.format), sourceFormat: convertSourceFormat(entry.sourceFormat), - rtlProperty: entry.rtlProperty + rtlProperty: entry.rtlProperty, + rtlActiveValues: entry.rtlActiveValues ) } @@ -82,7 +87,8 @@ struct ImagesLoaderConfig { scales: entry.scales, format: entry.format.flatMap { convertFlutterFormat($0) }, sourceFormat: convertSourceFormat(entry.sourceFormat), - rtlProperty: entry.rtlProperty + rtlProperty: entry.rtlProperty, + rtlActiveValues: entry.rtlActiveValues ) } @@ -95,7 +101,8 @@ struct ImagesLoaderConfig { scales: nil, format: .svg, // Web uses SVG by default sourceFormat: .svg, // Web always uses SVG source - rtlProperty: entry.rtlProperty + rtlProperty: entry.rtlProperty, + rtlActiveValues: entry.rtlActiveValues ) } @@ -108,7 +115,8 @@ struct ImagesLoaderConfig { scales: nil, format: nil, sourceFormat: .png, - rtlProperty: Component.defaultRTLProperty + rtlProperty: Component.defaultRTLProperty, + rtlActiveValues: ["On"] ) } @@ -273,6 +281,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di filter: filter, scales: scales, rtlProperty: config.rtlProperty, + rtlActiveValues: config.rtlActiveValues, onBatchProgress: onBatchProgress ) let (lightImages, darkImages) = splitByDarkMode(images, darkSuffix: darkSuffix) @@ -287,6 +296,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di params: SVGParams(), filter: filter, rtlProperty: config.rtlProperty, + rtlActiveValues: config.rtlActiveValues, onBatchProgress: onBatchProgress ) let (lightPack, darkPack) = splitByDarkMode(pack, darkSuffix: darkSuffix) @@ -344,6 +354,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di filter: filter, scales: scales, rtlProperty: self.config.rtlProperty, + rtlActiveValues: self.config.rtlActiveValues, onBatchProgress: onBatchProgress ) return (key, images) @@ -382,6 +393,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di params: SVGParams(), filter: filter, rtlProperty: self.config.rtlProperty, + rtlActiveValues: self.config.rtlActiveValues, onBatchProgress: onBatchProgress ) return (key, packs) @@ -422,6 +434,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di filter: filter, scales: scales, rtlProperty: config.rtlProperty, + rtlActiveValues: config.rtlActiveValues, onBatchProgress: onBatchProgress ) @@ -455,6 +468,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di params: SVGParams(), filter: filter, rtlProperty: config.rtlProperty, + rtlActiveValues: config.rtlActiveValues, onBatchProgress: onBatchProgress ) @@ -523,6 +537,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di filter: filter, scales: scales, rtlProperty: self.config.rtlProperty, + rtlActiveValues: self.config.rtlActiveValues, onBatchProgress: onBatchProgress ) return FileGranularResult( @@ -542,6 +557,7 @@ final class ImagesLoader: ImageLoaderBase, @unchecked Sendable { // swiftlint:di params: SVGParams(), filter: filter, rtlProperty: self.config.rtlProperty, + rtlActiveValues: self.config.rtlActiveValues, onBatchProgress: onBatchProgress ) return FileGranularResult( diff --git a/Sources/ExFigCLI/Resources/Schemas/Common.pkl b/Sources/ExFigCLI/Resources/Schemas/Common.pkl index 488a8017..8fbfc837 100644 --- a/Sources/ExFigCLI/Resources/Schemas/Common.pkl +++ b/Sources/ExFigCLI/Resources/Schemas/Common.pkl @@ -183,6 +183,13 @@ open class FrameSource extends NameProcessing { /// Set to null to disable variant-based RTL detection. rtlProperty: String? = "RTL" + /// Values of the RTL variant property that indicate "active" RTL direction. + /// Components with these values are skipped during export — the base variant + /// is mirrored at runtime by the platform. + /// Default: `new { "On" }`. Set to `new { "On"; "true" }` if your Figma file + /// uses both naming conventions. + rtlActiveValues: Listing? = new { "On" } + /// Dark mode generation via Figma Variable bindings. /// When set, dark SVG variants are generated by resolving variable bindings /// and replacing colors in the light SVG. diff --git a/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl b/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl index d1c8b6a4..36c4c09b 100644 --- a/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl +++ b/Sources/ExFigCLI/Resources/Schemas/examples/exfig-ios.pkl @@ -48,6 +48,7 @@ ios = new iOS.iOSConfig { xcassetsPath = "BrandKit/Assets.xcassets" templatesPath = "BrandKit/Templates" // rtlProperty = "RTL" // default; set to null to disable variant-based RTL detection + // rtlActiveValues = new { "On" } // default; add "true" if your Figma uses true/false naming } // Variable-mode dark: generate dark SVGs from Figma Variable bindings. // variablesFileId is required when variables alias to primitives in an external library. diff --git a/Sources/ExFigCLI/Source/FigmaComponentsSource.swift b/Sources/ExFigCLI/Source/FigmaComponentsSource.swift index 7b1cfff5..aff5aa1f 100644 --- a/Sources/ExFigCLI/Source/FigmaComponentsSource.swift +++ b/Sources/ExFigCLI/Source/FigmaComponentsSource.swift @@ -23,7 +23,8 @@ struct FigmaComponentsSource: ComponentsSource { renderModeDefaultSuffix: input.renderModeDefaultSuffix, renderModeOriginalSuffix: input.renderModeOriginalSuffix, renderModeTemplateSuffix: input.renderModeTemplateSuffix, - rtlProperty: input.rtlProperty + rtlProperty: input.rtlProperty, + rtlActiveValues: input.rtlActiveValues ) let loader = IconsLoader( @@ -87,7 +88,8 @@ struct FigmaComponentsSource: ComponentsSource { scales: input.scales, format: nil, sourceFormat: loaderSourceFormat, - rtlProperty: input.rtlProperty + rtlProperty: input.rtlProperty, + rtlActiveValues: input.rtlActiveValues ) let loader = ImagesLoader( diff --git a/Sources/ExFigConfig/Generated/Android.pkl.swift b/Sources/ExFigConfig/Generated/Android.pkl.swift index c676aefc..5f353743 100644 --- a/Sources/ExFigConfig/Generated/Android.pkl.swift +++ b/Sources/ExFigConfig/Generated/Android.pkl.swift @@ -284,6 +284,13 @@ extension Android { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Values of the RTL variant property that indicate "active" RTL direction. + /// Components with these values are skipped during export — the base variant + /// is mirrored at runtime by the platform. + /// Default: `new { "On" }`. Set to `new { "On"; "true" }` if your Figma file + /// uses both naming conventions. + public var rtlActiveValues: [String]? + /// Dark mode generation via Figma Variable bindings. /// When set, dark SVG variants are generated by resolving variable bindings /// and replacing colors in the light SVG. @@ -313,6 +320,7 @@ extension Android { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + rtlActiveValues: [String]?, variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? @@ -334,6 +342,7 @@ extension Android { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.rtlActiveValues = rtlActiveValues self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp @@ -401,6 +410,13 @@ extension Android { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Values of the RTL variant property that indicate "active" RTL direction. + /// Components with these values are skipped during export — the base variant + /// is mirrored at runtime by the platform. + /// Default: `new { "On" }`. Set to `new { "On"; "true" }` if your Figma file + /// uses both naming conventions. + public var rtlActiveValues: [String]? + /// Dark mode generation via Figma Variable bindings. /// When set, dark SVG variants are generated by resolving variable bindings /// and replacing colors in the light SVG. @@ -428,6 +444,7 @@ extension Android { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + rtlActiveValues: [String]?, variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? @@ -447,6 +464,7 @@ extension Android { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.rtlActiveValues = rtlActiveValues self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp diff --git a/Sources/ExFigConfig/Generated/Common.pkl.swift b/Sources/ExFigConfig/Generated/Common.pkl.swift index 9f2f875a..efa9f331 100644 --- a/Sources/ExFigConfig/Generated/Common.pkl.swift +++ b/Sources/ExFigConfig/Generated/Common.pkl.swift @@ -44,6 +44,8 @@ public protocol Common_FrameSource: Common_NameProcessing { var rtlProperty: String? { get } + var rtlActiveValues: [String]? { get } + var variablesDarkMode: Common.VariablesDarkMode? { get } } @@ -343,6 +345,13 @@ extension Common { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Values of the RTL variant property that indicate "active" RTL direction. + /// Components with these values are skipped during export — the base variant + /// is mirrored at runtime by the platform. + /// Default: `new { "On" }`. Set to `new { "On"; "true" }` if your Figma file + /// uses both naming conventions. + public var rtlActiveValues: [String]? + /// Dark mode generation via Figma Variable bindings. /// When set, dark SVG variants are generated by resolving variable bindings /// and replacing colors in the light SVG. @@ -361,6 +370,7 @@ extension Common { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + rtlActiveValues: [String]?, variablesDarkMode: VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? @@ -371,6 +381,7 @@ extension Common { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.rtlActiveValues = rtlActiveValues self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp diff --git a/Sources/ExFigConfig/Generated/Flutter.pkl.swift b/Sources/ExFigConfig/Generated/Flutter.pkl.swift index 871b6aa4..2382e728 100644 --- a/Sources/ExFigConfig/Generated/Flutter.pkl.swift +++ b/Sources/ExFigConfig/Generated/Flutter.pkl.swift @@ -154,6 +154,13 @@ extension Flutter { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Values of the RTL variant property that indicate "active" RTL direction. + /// Components with these values are skipped during export — the base variant + /// is mirrored at runtime by the platform. + /// Default: `new { "On" }`. Set to `new { "On"; "true" }` if your Figma file + /// uses both naming conventions. + public var rtlActiveValues: [String]? + /// Dark mode generation via Figma Variable bindings. /// When set, dark SVG variants are generated by resolving variable bindings /// and replacing colors in the light SVG. @@ -177,6 +184,7 @@ extension Flutter { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + rtlActiveValues: [String]?, variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? @@ -192,6 +200,7 @@ extension Flutter { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.rtlActiveValues = rtlActiveValues self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp @@ -258,6 +267,13 @@ extension Flutter { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Values of the RTL variant property that indicate "active" RTL direction. + /// Components with these values are skipped during export — the base variant + /// is mirrored at runtime by the platform. + /// Default: `new { "On" }`. Set to `new { "On"; "true" }` if your Figma file + /// uses both naming conventions. + public var rtlActiveValues: [String]? + /// Dark mode generation via Figma Variable bindings. /// When set, dark SVG variants are generated by resolving variable bindings /// and replacing colors in the light SVG. @@ -285,6 +301,7 @@ extension Flutter { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + rtlActiveValues: [String]?, variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? @@ -304,6 +321,7 @@ extension Flutter { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.rtlActiveValues = rtlActiveValues self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp diff --git a/Sources/ExFigConfig/Generated/Web.pkl.swift b/Sources/ExFigConfig/Generated/Web.pkl.swift index 5ae9e0cf..ea402ed3 100644 --- a/Sources/ExFigConfig/Generated/Web.pkl.swift +++ b/Sources/ExFigConfig/Generated/Web.pkl.swift @@ -166,6 +166,13 @@ extension Web { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Values of the RTL variant property that indicate "active" RTL direction. + /// Components with these values are skipped during export — the base variant + /// is mirrored at runtime by the platform. + /// Default: `new { "On" }`. Set to `new { "On"; "true" }` if your Figma file + /// uses both naming conventions. + public var rtlActiveValues: [String]? + /// Dark mode generation via Figma Variable bindings. /// When set, dark SVG variants are generated by resolving variable bindings /// and replacing colors in the light SVG. @@ -190,6 +197,7 @@ extension Web { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + rtlActiveValues: [String]?, variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? @@ -206,6 +214,7 @@ extension Web { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.rtlActiveValues = rtlActiveValues self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp @@ -260,6 +269,13 @@ extension Web { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Values of the RTL variant property that indicate "active" RTL direction. + /// Components with these values are skipped during export — the base variant + /// is mirrored at runtime by the platform. + /// Default: `new { "On" }`. Set to `new { "On"; "true" }` if your Figma file + /// uses both naming conventions. + public var rtlActiveValues: [String]? + /// Dark mode generation via Figma Variable bindings. /// When set, dark SVG variants are generated by resolving variable bindings /// and replacing colors in the light SVG. @@ -283,6 +299,7 @@ extension Web { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + rtlActiveValues: [String]?, variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? @@ -298,6 +315,7 @@ extension Web { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.rtlActiveValues = rtlActiveValues self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp diff --git a/Sources/ExFigConfig/Generated/iOS.pkl.swift b/Sources/ExFigConfig/Generated/iOS.pkl.swift index bfc3dbc8..891f549f 100644 --- a/Sources/ExFigConfig/Generated/iOS.pkl.swift +++ b/Sources/ExFigConfig/Generated/iOS.pkl.swift @@ -253,6 +253,13 @@ extension iOS { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Values of the RTL variant property that indicate "active" RTL direction. + /// Components with these values are skipped during export — the base variant + /// is mirrored at runtime by the platform. + /// Default: `new { "On" }`. Set to `new { "On"; "true" }` if your Figma file + /// uses both naming conventions. + public var rtlActiveValues: [String]? + /// Dark mode generation via Figma Variable bindings. /// When set, dark SVG variants are generated by resolving variable bindings /// and replacing colors in the light SVG. @@ -284,6 +291,7 @@ extension iOS { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + rtlActiveValues: [String]?, variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? @@ -307,6 +315,7 @@ extension iOS { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.rtlActiveValues = rtlActiveValues self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp @@ -392,6 +401,13 @@ extension iOS { /// Set to null to disable variant-based RTL detection. public var rtlProperty: String? + /// Values of the RTL variant property that indicate "active" RTL direction. + /// Components with these values are skipped during export — the base variant + /// is mirrored at runtime by the platform. + /// Default: `new { "On" }`. Set to `new { "On"; "true" }` if your Figma file + /// uses both naming conventions. + public var rtlActiveValues: [String]? + /// Dark mode generation via Figma Variable bindings. /// When set, dark SVG variants are generated by resolving variable bindings /// and replacing colors in the light SVG. @@ -425,6 +441,7 @@ extension iOS { figmaPageName: String?, figmaFileId: String?, rtlProperty: String?, + rtlActiveValues: [String]?, variablesDarkMode: Common.VariablesDarkMode?, nameValidateRegexp: String?, nameReplaceRegexp: String? @@ -450,6 +467,7 @@ extension iOS { self.figmaPageName = figmaPageName self.figmaFileId = figmaFileId self.rtlProperty = rtlProperty + self.rtlActiveValues = rtlActiveValues self.variablesDarkMode = variablesDarkMode self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp diff --git a/Sources/ExFigCore/Protocol/IconsExportContext.swift b/Sources/ExFigCore/Protocol/IconsExportContext.swift index 740e24e4..e154b109 100644 --- a/Sources/ExFigCore/Protocol/IconsExportContext.swift +++ b/Sources/ExFigCore/Protocol/IconsExportContext.swift @@ -94,6 +94,11 @@ public struct IconsSourceInput: Sendable { /// Default: `"RTL"`. Set to `nil` to disable variant-based RTL detection. public let rtlProperty: String? + /// Values of the RTL variant property that indicate "active" RTL direction. + /// Components with these values are skipped during export. + /// Default: `["On"]`. + public let rtlActiveValues: [String]? + /// Name validation regex. public let nameValidateRegexp: String? @@ -132,6 +137,7 @@ public struct IconsSourceInput: Sendable { renderModeOriginalSuffix: String? = nil, renderModeTemplateSuffix: String? = nil, rtlProperty: String? = "RTL", + rtlActiveValues: [String]? = ["On"], nameValidateRegexp: String? = nil, nameReplaceRegexp: String? = nil, penpotBaseURL: String? = nil, @@ -154,6 +160,7 @@ public struct IconsSourceInput: Sendable { self.renderModeOriginalSuffix = renderModeOriginalSuffix self.renderModeTemplateSuffix = renderModeTemplateSuffix self.rtlProperty = rtlProperty + self.rtlActiveValues = rtlActiveValues self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp self.penpotBaseURL = penpotBaseURL diff --git a/Sources/ExFigCore/Protocol/ImagesExportContext.swift b/Sources/ExFigCore/Protocol/ImagesExportContext.swift index 9e73bd40..514ce09a 100644 --- a/Sources/ExFigCore/Protocol/ImagesExportContext.swift +++ b/Sources/ExFigCore/Protocol/ImagesExportContext.swift @@ -193,6 +193,11 @@ public struct ImagesSourceInput: Sendable { /// Default: `"RTL"`. Set to `nil` to disable variant-based RTL detection. public let rtlProperty: String? + /// Values of the RTL variant property that indicate "active" RTL direction. + /// Components with these values are skipped during export. + /// Default: `["On"]`. + public let rtlActiveValues: [String]? + /// Name validation regex. public let nameValidateRegexp: String? @@ -213,6 +218,7 @@ public struct ImagesSourceInput: Sendable { useSingleFile: Bool = false, darkModeSuffix: String = "_dark", rtlProperty: String? = "RTL", + rtlActiveValues: [String]? = ["On"], nameValidateRegexp: String? = nil, nameReplaceRegexp: String? = nil, penpotBaseURL: String? = nil @@ -227,6 +233,7 @@ public struct ImagesSourceInput: Sendable { self.useSingleFile = useSingleFile self.darkModeSuffix = darkModeSuffix self.rtlProperty = rtlProperty + self.rtlActiveValues = rtlActiveValues self.nameValidateRegexp = nameValidateRegexp self.nameReplaceRegexp = nameReplaceRegexp self.penpotBaseURL = penpotBaseURL diff --git a/Tests/ExFigTests/Input/EnumBridgingTests.swift b/Tests/ExFigTests/Input/EnumBridgingTests.swift index 3651a2b2..125d19f4 100644 --- a/Tests/ExFigTests/Input/EnumBridgingTests.swift +++ b/Tests/ExFigTests/Input/EnumBridgingTests.swift @@ -120,6 +120,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + rtlActiveValues: nil, variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -166,6 +167,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + rtlActiveValues: nil, variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -208,6 +210,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + rtlActiveValues: nil, variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -238,6 +241,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + rtlActiveValues: nil, variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -274,6 +278,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + rtlActiveValues: nil, variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -302,6 +307,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + rtlActiveValues: nil, variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -367,6 +373,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + rtlActiveValues: nil, variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -402,6 +409,7 @@ final class EnumBridgingTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + rtlActiveValues: nil, variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil diff --git a/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift b/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift index a24184b6..fa86e9ce 100644 --- a/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift +++ b/Tests/ExFigTests/Input/PenpotDesignSourceTests.swift @@ -28,6 +28,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: "figma-file-id", rtlProperty: nil, + rtlActiveValues: nil, variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -58,6 +59,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + rtlActiveValues: nil, variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -88,6 +90,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: "figma-file-id", rtlProperty: nil, + rtlActiveValues: nil, variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -118,6 +121,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: "figma-file-id", rtlProperty: nil, + rtlActiveValues: nil, variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -146,6 +150,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: "figma-file-id", rtlProperty: nil, + rtlActiveValues: nil, variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -176,6 +181,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + rtlActiveValues: nil, variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil @@ -204,6 +210,7 @@ final class FrameSourceResolvedSourceKindTests: XCTestCase { figmaPageName: nil, figmaFileId: nil, rtlProperty: nil, + rtlActiveValues: nil, variablesDarkMode: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil diff --git a/Tests/ExFigTests/Lint/LintRulesTests.swift b/Tests/ExFigTests/Lint/LintRulesTests.swift index 3fc40fa8..31c8f7f2 100644 --- a/Tests/ExFigTests/Lint/LintRulesTests.swift +++ b/Tests/ExFigTests/Lint/LintRulesTests.swift @@ -23,6 +23,8 @@ private func makeIOSIconsConfig( frameName: String? = nil, pageName: String? = nil, nameValidateRegexp: String? = nil, + rtlProperty: String? = nil, + rtlActiveValues: [String]? = nil, suffixDarkMode: String? = nil ) -> PKLConfig { var entryParts: [String] = [ @@ -33,6 +35,11 @@ private func makeIOSIconsConfig( if let frameName { entryParts.append("\"figmaFrameName\": \"\(frameName)\"") } if let pageName { entryParts.append("\"figmaPageName\": \"\(pageName)\"") } if let regex = nameValidateRegexp { entryParts.append("\"nameValidateRegexp\": \"\(regex)\"") } + if let rtlProperty { entryParts.append("\"rtlProperty\": \"\(rtlProperty)\"") } + if let rtlActiveValues { + let valuesJson = rtlActiveValues.map { "\"\($0)\"" }.joined(separator: ", ") + entryParts.append("\"rtlActiveValues\": [\(valuesJson)]") + } var commonParts: [String] = [] if let suffix = suffixDarkMode { @@ -630,7 +637,7 @@ struct LintEngineTests { #expect(!diagnostics.contains { $0.ruleId == "component-not-frame" }) } - @Test("default engine registers all 9 rules") + @Test("default engine registers all 10 rules") func defaultEngineHasAllRules() { let ruleIds = Set(LintEngine.default.rules.map(\.id)) let expected: Set = [ @@ -643,6 +650,7 @@ struct LintEngineTests { "dark-mode-variables", "dark-mode-suffix", "path-data-length", + "invalid-rtl-variant-value", ] #expect(ruleIds == expected) } @@ -932,4 +940,195 @@ struct PathDataLengthRuleTests { } } +// MARK: - InvalidRTLVariantValueRule Tests + +struct InvalidRTLVariantValueRuleTests { + let rule = InvalidRTLVariantValueRule() + + @Test("passes when RTL variants use valid Off/On values") + func passesWithValidValues() async throws { + let client = MockClient() + client.setResponse([ + makeVariantComponent(nodeId: "1:1", name: "RTL=Off", frameName: "Icons", componentSetName: "arrow"), + makeVariantComponent(nodeId: "1:2", name: "RTL=On", frameName: "Icons", componentSetName: "arrow"), + ], for: ComponentsEndpoint.self) + + let config = makeIOSIconsConfig(frameName: "Icons", rtlProperty: "RTL") + let context = makeLintContext(config: config, client: client) + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.isEmpty) + } + + @Test("error when true/false used instead of Off/On") + func errorWhenTrueFalseUsed() async throws { + let client = MockClient() + client.setResponse([ + makeVariantComponent(nodeId: "1:1", name: "RTL=false", frameName: "Icons", componentSetName: "car"), + makeVariantComponent(nodeId: "1:2", name: "RTL=true", frameName: "Icons", componentSetName: "car"), + ], for: ComponentsEndpoint.self) + + let config = makeIOSIconsConfig(frameName: "Icons", rtlProperty: "RTL") + let context = makeLintContext(config: config, client: client) + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.count == 2) + #expect(diagnostics.allSatisfy { $0.severity == .error }) + #expect(diagnostics.allSatisfy { $0.ruleId == "invalid-rtl-variant-value" }) + } + + @Test("passes for non-variant components (no containingComponentSet)") + func passesNonVariantComponents() async throws { + let client = MockClient() + client.setResponse([ + Component.make(nodeId: "1:1", name: "icon_home", frameName: "Icons"), + ], for: ComponentsEndpoint.self) + + let config = makeIOSIconsConfig(frameName: "Icons", rtlProperty: "RTL") + let context = makeLintContext(config: config, client: client) + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.isEmpty) + } + + @Test("passes for variants without RTL property") + func passesComponentsWithoutRTLProperty() async throws { + let client = MockClient() + client.setResponse([ + makeVariantComponent(nodeId: "1:1", name: "Size=Small", frameName: "Icons", componentSetName: "button"), + ], for: ComponentsEndpoint.self) + + let config = makeIOSIconsConfig(frameName: "Icons", rtlProperty: "RTL") + let context = makeLintContext(config: config, client: client) + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.isEmpty) + } + + @Test("supports custom rtlProperty name") + func supportsCustomRTLProperty() async throws { + let client = MockClient() + client.setResponse([ + makeVariantComponent(nodeId: "1:1", name: "Direction=yes", frameName: "Icons", componentSetName: "arrow"), + ], for: ComponentsEndpoint.self) + + let config = makeIOSIconsConfig(frameName: "Icons", rtlProperty: "Direction") + let context = makeLintContext(config: config, client: client) + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.count == 1) + #expect(diagnostics.first?.message.contains("Direction=yes") == true) + } + + @Test("skips entries with nil rtlProperty (RTL disabled)") + func skipsEntriesWithNilRTLProperty() async throws { + let client = MockClient() + client.setResponse([ + makeVariantComponent(nodeId: "1:1", name: "RTL=true", frameName: "Icons", componentSetName: "car"), + ], for: ComponentsEndpoint.self) + + // No rtlProperty in config → entry skipped → no diagnostics + let config = makeIOSIconsConfig(frameName: "Icons") + let context = makeLintContext(config: config, client: client) + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.isEmpty) + } + + @Test("handles empty fileId with diagnostic") + func handlesEmptyFileId() async throws { + let client = MockClient() + let config = makeIOSIconsConfig(lightFileId: "", frameName: "Icons", rtlProperty: "RTL") + let context = makeLintContext(config: config, client: client) + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.count == 1) + #expect(diagnostics.first?.message.contains("No figma.lightFileId") == true) + } + + @Test("emits error when components API fails") + func emitsErrorWhenComponentsFail() async throws { + let client = MockClient() + client.setError( + NSError(domain: "test", code: -1, userInfo: [NSLocalizedDescriptionKey: "API error"]), + for: ComponentsEndpoint.self + ) + + let config = makeIOSIconsConfig(frameName: "Icons", rtlProperty: "RTL") + let context = makeLintContext(config: config, client: client) + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.count == 1) + #expect(diagnostics.first?.severity == .error) + #expect(diagnostics.first?.message.contains("Cannot fetch components") == true) + } + + @Test("suggests adding value to rtlActiveValues or renaming in Figma") + func suggestsCorrectAction() { + let entries = [InvalidRTLVariantValueRule.IconEntry( + fileId: "abc", frameName: "Icons", pageName: nil, rtlProperty: "RTL", + rtlActiveValues: ["On"] + )] + + let components = [ + makeVariantComponent(nodeId: "1:1", name: "RTL=true", frameName: "Icons", componentSetName: "car"), + ] + + let diagnostics = rule.validateRTLValues(components: components, entries: entries) + + #expect(diagnostics.count == 1) + #expect(diagnostics.first?.suggestion?.contains("rtlActiveValues") == true) + } + + @Test("passes when rtlActiveValues includes true/false") + func passesWithConfiguredTrueFalse() async throws { + let client = MockClient() + client.setResponse([ + makeVariantComponent(nodeId: "1:1", name: "RTL=false", frameName: "Icons", componentSetName: "car"), + makeVariantComponent(nodeId: "1:2", name: "RTL=true", frameName: "Icons", componentSetName: "car"), + ], for: ComponentsEndpoint.self) + + let config = makeIOSIconsConfig(frameName: "Icons", rtlProperty: "RTL", rtlActiveValues: ["true"]) + let context = makeLintContext(config: config, client: client) + let diagnostics = try await rule.check(context: context) + + #expect(diagnostics.isEmpty) + } + + @Test("validValues builds correct set from active values") + func validValuesBuildCorrectSet() { + let valid = InvalidRTLVariantValueRule.validValues(for: ["On"]) + #expect(valid == ["Off", "On"]) + + let valid2 = InvalidRTLVariantValueRule.validValues(for: ["true"]) + #expect(valid2 == ["false", "true"]) + + let valid3 = InvalidRTLVariantValueRule.validValues(for: ["On", "true"]) + #expect(valid3 == ["Off", "On", "false", "true"]) + + // Custom value not in knownPairs — only the value itself, no counterpart + let valid4 = InvalidRTLVariantValueRule.validValues(for: ["Active"]) + #expect(valid4 == ["Active"]) + } + + @Test("validates mixed valid and invalid components in same set") + func mixedValidAndInvalidComponents() { + let entries = [InvalidRTLVariantValueRule.IconEntry( + fileId: "abc", frameName: "Icons", pageName: nil, rtlProperty: "RTL", + rtlActiveValues: ["On"] + )] + + let components = [ + makeVariantComponent(nodeId: "1:1", name: "RTL=Off", frameName: "Icons", componentSetName: "arrow"), + makeVariantComponent(nodeId: "1:2", name: "RTL=On", frameName: "Icons", componentSetName: "arrow"), + makeVariantComponent(nodeId: "2:1", name: "RTL=true", frameName: "Icons", componentSetName: "car"), + ] + + let diagnostics = rule.validateRTLValues(components: components, entries: entries) + + #expect(diagnostics.count == 1) + #expect(diagnostics.first?.message.contains("RTL=true") == true) + } +} + // swiftlint:enable file_length diff --git a/Tests/ExFigTests/Loaders/ComponentRTLTests.swift b/Tests/ExFigTests/Loaders/ComponentRTLTests.swift index 971b8afa..ca0fd876 100644 --- a/Tests/ExFigTests/Loaders/ComponentRTLTests.swift +++ b/Tests/ExFigTests/Loaders/ComponentRTLTests.swift @@ -100,6 +100,41 @@ final class ComponentRTLTests: XCTestCase { XCTAssertTrue(component.shouldSkipAsRTLVariant(propertyName: "RTL")) } + func testShouldSkip_customActiveValues_true() { + let component = makeComponent(name: "RTL=true") + XCTAssertTrue(component.shouldSkipAsRTLVariant(propertyName: "RTL", activeValues: ["true"])) + } + + func testShouldNotSkip_customActiveValues_false() { + let component = makeComponent(name: "RTL=false") + XCTAssertFalse(component.shouldSkipAsRTLVariant(propertyName: "RTL", activeValues: ["true"])) + } + + func testShouldSkip_customActiveValues_Yes() { + let component = makeComponent(name: "RTL=Yes") + XCTAssertTrue(component.shouldSkipAsRTLVariant(propertyName: "RTL", activeValues: ["Yes"])) + } + + func testShouldNotSkip_customActiveValues_mismatch() { + let component = makeComponent(name: "RTL=On") + XCTAssertFalse(component.shouldSkipAsRTLVariant(propertyName: "RTL", activeValues: ["true"])) + } + + func testShouldSkip_multipleActiveValues() { + let component = makeComponent(name: "RTL=true") + XCTAssertTrue(component.shouldSkipAsRTLVariant(propertyName: "RTL", activeValues: ["On", "true"])) + } + + func testShouldSkip_nilActiveValues_defaultsToOn() { + let component = makeComponent(name: "RTL=On") + XCTAssertTrue(component.shouldSkipAsRTLVariant(propertyName: "RTL", activeValues: nil)) + } + + func testShouldNotSkip_nilActiveValues_trueNotSkipped() { + let component = makeComponent(name: "RTL=true") + XCTAssertFalse(component.shouldSkipAsRTLVariant(propertyName: "RTL", activeValues: nil)) + } + // MARK: - useRTL func testUseRTL_variantPropertyPresent_returnsTrue() { diff --git a/Tests/ExFigTests/Loaders/IconsLoaderConfigTests.swift b/Tests/ExFigTests/Loaders/IconsLoaderConfigTests.swift index 1ad3e580..3b006b34 100644 --- a/Tests/ExFigTests/Loaders/IconsLoaderConfigTests.swift +++ b/Tests/ExFigTests/Loaders/IconsLoaderConfigTests.swift @@ -222,7 +222,8 @@ final class IconsLoaderConfigTests: XCTestCase { renderModeDefaultSuffix: nil, renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, - rtlProperty: nil + rtlProperty: nil, + rtlActiveValues: nil ) XCTAssertEqual(config.format, .svg) @@ -238,7 +239,8 @@ final class IconsLoaderConfigTests: XCTestCase { renderModeDefaultSuffix: nil, renderModeOriginalSuffix: nil, renderModeTemplateSuffix: nil, - rtlProperty: nil + rtlProperty: nil, + rtlActiveValues: nil ) XCTAssertEqual(config.format, .pdf) @@ -263,7 +265,8 @@ final class IconsLoaderConfigTests: XCTestCase { renderModeDefaultSuffix: source.renderModeDefaultSuffix, renderModeOriginalSuffix: source.renderModeOriginalSuffix, renderModeTemplateSuffix: source.renderModeTemplateSuffix, - rtlProperty: source.rtlProperty + rtlProperty: source.rtlProperty, + rtlActiveValues: source.rtlActiveValues ) XCTAssertEqual(config.format, .svg, "SVG format must survive source → config conversion") } @@ -283,7 +286,8 @@ final class IconsLoaderConfigTests: XCTestCase { renderModeDefaultSuffix: source.renderModeDefaultSuffix, renderModeOriginalSuffix: source.renderModeOriginalSuffix, renderModeTemplateSuffix: source.renderModeTemplateSuffix, - rtlProperty: source.rtlProperty + rtlProperty: source.rtlProperty, + rtlActiveValues: source.rtlActiveValues ) XCTAssertEqual(config.format, .pdf) } @@ -307,9 +311,15 @@ final class IconsLoaderConfigTests: XCTestCase { renderModeDefaultSuffix: source.renderModeDefaultSuffix, renderModeOriginalSuffix: source.renderModeOriginalSuffix, renderModeTemplateSuffix: source.renderModeTemplateSuffix, - rtlProperty: source.rtlProperty + rtlProperty: source.rtlProperty, + rtlActiveValues: source.rtlActiveValues ) XCTAssertEqual(config.rtlProperty, "RTL", "rtlProperty must survive source → config conversion") + XCTAssertEqual( + config.rtlActiveValues, + source.rtlActiveValues, + "rtlActiveValues must survive source → config conversion" + ) } func testRTLPropertyNilPreservedThroughEntryToSource() throws { @@ -395,7 +405,8 @@ final class IconsLoaderConfigTests: XCTestCase { renderModeDefaultSuffix: source.renderModeDefaultSuffix, renderModeOriginalSuffix: source.renderModeOriginalSuffix, renderModeTemplateSuffix: source.renderModeTemplateSuffix, - rtlProperty: source.rtlProperty + rtlProperty: source.rtlProperty, + rtlActiveValues: source.rtlActiveValues ) XCTAssertEqual(config.pageName, "Outlined", "pageName must survive source → config conversion") } diff --git a/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift b/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift index 77611144..aa61bcde 100644 --- a/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift +++ b/Tests/ExFigTests/Loaders/ImagesLoaderConfigTests.swift @@ -265,7 +265,8 @@ final class ImagesLoaderConfigTests: XCTestCase { scales: source.scales, format: nil, sourceFormat: .png, - rtlProperty: source.rtlProperty + rtlProperty: source.rtlProperty, + rtlActiveValues: source.rtlActiveValues ) XCTAssertEqual(config.rtlProperty, "RTL", "rtlProperty must survive source → config conversion") } @@ -342,7 +343,8 @@ final class ImagesLoaderConfigTests: XCTestCase { scales: source.scales, format: nil, sourceFormat: .png, - rtlProperty: source.rtlProperty + rtlProperty: source.rtlProperty, + rtlActiveValues: source.rtlActiveValues ) XCTAssertEqual(config.pageName, "Marketing", "pageName must survive source → config conversion") } diff --git a/llms-full.txt b/llms-full.txt index 9cb3cf2b..2fc1db9f 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -799,6 +799,31 @@ common = new Common.CommonConfig { } ``` +### RTL (Right-to-Left) Configuration + +Icons in a COMPONENT_SET can have an RTL variant property (e.g., `RTL=Off` and `RTL=On`). +The "active" variant is skipped during export — platforms mirror the base icon at runtime. + +```pkl +new iOS.IconsEntry { + // Property name in Figma (default: "RTL") + rtlProperty = "RTL" + + // Values meaning "active RTL" — these variants are skipped. + // Default: new { "On" } (paired with "Off") + rtlActiveValues = new { "On" } + + // If your Figma uses true/false instead of On/Off: + // rtlActiveValues = new { "true" } +} +``` + +Set `rtlProperty = null` to disable RTL detection entirely. + +The `exfig lint` rule `invalid-rtl-variant-value` validates that RTL variant values +match the configured `rtlActiveValues` and their known counterparts +(On↔Off, true↔false, True↔False, Yes↔No, 0↔1). + ### Images ```pkl