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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/rules/lint-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .claude/rules/source-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>? = 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

Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-Android/Config/AndroidIconsEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-Android/Config/AndroidImagesEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public extension Android.ImagesEntry {
useSingleFile: darkFileId == nil,
darkModeSuffix: "_dark",
rtlProperty: rtlProperty,
rtlActiveValues: rtlActiveValues,
nameValidateRegexp: nameValidateRegexp,
nameReplaceRegexp: nameReplaceRegexp,
penpotBaseURL: resolvedPenpotBaseURL
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-Android/Export/AndroidImagesExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ private extension AndroidImagesExporter {
useSingleFile: true,
darkModeSuffix: "_dark",
rtlProperty: entry.rtlProperty,
rtlActiveValues: entry.rtlActiveValues,
nameValidateRegexp: entry.nameValidateRegexp,
nameReplaceRegexp: entry.nameReplaceRegexp
)
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-Flutter/Config/FlutterIconsEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions Sources/ExFig-Flutter/Config/FlutterImagesEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public extension Flutter.ImagesEntry {
useSingleFile: darkFileId == nil,
darkModeSuffix: "_dark",
rtlProperty: rtlProperty,
rtlActiveValues: rtlActiveValues,
nameValidateRegexp: nameValidateRegexp,
nameReplaceRegexp: nameReplaceRegexp,
penpotBaseURL: resolvedPenpotBaseURL
Expand Down Expand Up @@ -55,6 +56,7 @@ public extension Flutter.ImagesEntry {
useSingleFile: darkFileId == nil,
darkModeSuffix: "_dark",
rtlProperty: rtlProperty,
rtlActiveValues: rtlActiveValues,
nameValidateRegexp: nameValidateRegexp,
nameReplaceRegexp: nameReplaceRegexp,
penpotBaseURL: resolvedPenpotBaseURL
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-Web/Config/WebIconsEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-Web/Config/WebImagesEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public extension Web.ImagesEntry {
useSingleFile: darkFileId == nil,
darkModeSuffix: "_dark",
rtlProperty: rtlProperty,
rtlActiveValues: rtlActiveValues,
nameValidateRegexp: nameValidateRegexp,
nameReplaceRegexp: nameReplaceRegexp,
penpotBaseURL: resolvedPenpotBaseURL
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-iOS/Config/iOSIconsEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public extension iOS.IconsEntry {
renderModeOriginalSuffix: renderModeOriginalSuffix,
renderModeTemplateSuffix: renderModeTemplateSuffix,
rtlProperty: rtlProperty,
rtlActiveValues: rtlActiveValues,
nameValidateRegexp: nameValidateRegexp,
nameReplaceRegexp: nameReplaceRegexp,
penpotBaseURL: resolvedPenpotBaseURL,
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-iOS/Config/iOSImagesEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public extension iOS.ImagesEntry {
useSingleFile: darkFileId == nil,
darkModeSuffix: "_dark",
rtlProperty: rtlProperty,
rtlActiveValues: rtlActiveValues,
nameValidateRegexp: nameValidateRegexp,
nameReplaceRegexp: nameReplaceRegexp,
penpotBaseURL: resolvedPenpotBaseURL
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFig-iOS/Export/iOSImagesExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,7 @@ private extension iOSImagesEntry {
useSingleFile: true,
darkModeSuffix: "_dark",
rtlProperty: rtlProperty,
rtlActiveValues: rtlActiveValues,
nameValidateRegexp: nameValidateRegexp,
nameReplaceRegexp: nameReplaceRegexp
)
Expand Down
3 changes: 2 additions & 1 deletion Sources/ExFigCLI/Context/IconsExportContextImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion Sources/ExFigCLI/Context/ImagesExportContextImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
25 changes: 25 additions & 0 deletions Sources/ExFigCLI/ExFig.docc/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Sources/ExFigCLI/Lint/LintEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,6 @@ extension LintEngine {
DarkModeVariablesRule(),
DarkModeSuffixRule(),
PathDataLengthRule(),
InvalidRTLVariantValueRule(),
])
}
209 changes: 209 additions & 0 deletions Sources/ExFigCLI/Lint/Rules/InvalidRTLVariantValueRule.swift
Original file line number Diff line number Diff line change
@@ -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<String> {
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
}
Comment thread
alexey1312 marked this conversation as resolved.

// 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<String>()
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
}
}
}
Loading
Loading