diff --git a/CLAUDE.md b/CLAUDE.md index bb13a5fa..f13ed915 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,7 @@ and Flutter projects. .build/debug/exfig icons -i exfig.pkl .build/debug/exfig batch exfig.pkl # All resources from unified config (positional arg!) .build/debug/exfig fetch -f FILE_ID -r "Frame" -o ./output +.build/debug/exfig download tokens -o tokens.json # Unified W3C design tokens # PKL Validation (validate config templates against schemas) pkl eval --format json # Package URI requires published package @@ -120,7 +121,7 @@ pkl eval --format json # Package URI requires published package ## Architecture -Twelve modules in `Sources/`: +Fourteen modules in `Sources/`: | Module | Purpose | | --------------- | --------------------------------------------------------- | @@ -137,8 +138,10 @@ Twelve modules in `Sources/`: | `FlutterExport` | Flutter export (Dart code, SVG/PNG assets) | | `WebExport` | Web/React export (CSS variables, JSX icons) | | `SVGKit` | SVG parsing, ImageVector/VectorDrawable generation | +| `JinjaSupport` | Shared Jinja2 template rendering across Export modules | **Data flow:** CLI -> PKL config parsing -> FigmaAPI fetch -> ExFigCore processing -> Platform plugin -> Export module -> File write +**Alt data flow (tokens):** CLI -> local .tokens.json file -> TokensFileSource -> ExFigCore models -> W3C JSON export **Batch mode:** Single `@TaskLocal` via `BatchSharedState` actor — see `ExFigCLI/CLAUDE.md`. @@ -290,12 +293,13 @@ See `ExFigCore/CLAUDE.md` (Modification Checklist) and platform module CLAUDE.md ## Code Conventions -| Area | Use | Instead of | -| --------------- | --------------------------------- | ------------------------------------ | -| JSON parsing | `JSONCodec` (swift-yyjson) | `JSONDecoder`/`JSONEncoder` | -| Terminal UI | Noora (`NooraUI`, `TerminalText`) | Rainbow color methods | -| Terminal output | `TerminalUI` facade | Direct `print()` calls | -| README.md | Keep compact (~300 lines) | Detailed docs (use CONFIG.md / DocC) | +| Area | Use | Instead of | +| --------------- | --------------------------------- | ------------------------------------- | +| JSON parsing | `JSONCodec` (swift-yyjson) | `JSONDecoder`/`JSONEncoder` | +| JSON DOM access | `JSONCodec.parseValue(from:)` | `JSONSerialization` / `import YYJSON` | +| Terminal UI | Noora (`NooraUI`, `TerminalText`) | Rainbow color methods | +| Terminal output | `TerminalUI` facade | Direct `print()` calls | +| README.md | Keep compact (~300 lines) | Detailed docs (use CONFIG.md / DocC) | **JSONCodec usage:** @@ -307,6 +311,13 @@ let data = try JSONCodec.decode(MyType.self, from: jsonData) // Encode let jsonData = try JSONCodec.encode(myValue) + +// DOM access (for dynamic JSON without Codable types) +let json = try JSONCodec.parseValue(from: data) // returns JSONValue +let name = json["key"]?.string // String? +let count = json["count"]?.number // Double? +if let obj = json.object { for (k, v) in obj { } } // iterate keys +if let arr = json["items"]?.array { arr.compactMap(\.string) } // array ``` **Noora usage:** See `.claude/rules/terminal-ui.md` for full patterns. diff --git a/README.md b/README.md index caa3b9bf..94ff7821 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![CI](https://github.com/alexey1312/ExFig/actions/workflows/ci.yml/badge.svg)](https://github.com/alexey1312/ExFig/actions/workflows/ci.yml) [![Release](https://github.com/alexey1312/ExFig/actions/workflows/release.yml/badge.svg)](https://github.com/alexey1312/ExFig/actions/workflows/release.yml) [![Docs](https://github.com/alexey1312/ExFig/actions/workflows/deploy-docc.yml/badge.svg)](https://alexey1312.github.io/ExFig/documentation/exfig) -![Coverage](https://img.shields.io/badge/coverage-49.36%25-yellow) +![Coverage](https://img.shields.io/badge/coverage-50.65%25-yellow) [![License](https://img.shields.io/github/license/alexey1312/ExFig.svg)](LICENSE) Command-line utility to export colors, typography, icons, and images from Figma to Xcode, Android Studio, Flutter, and @@ -32,6 +32,7 @@ Flutter, and React/TypeScript. - 📝 Typography with Dynamic Type support (iOS) - 🔄 RTL (Right-to-Left) layout support - 🎯 Figma Variables support +- 📁 Local `.tokens.json` file import (no Figma API needed) ### Platform Support @@ -46,7 +47,7 @@ Flutter, and React/TypeScript. ### Export Formats - 🖼️ PNG, SVG, PDF, JPEG, WebP, HEIC (with quality control) -- 📊 W3C Design Tokens (JSON export) +- 📊 W3C Design Tokens (DTCG v2025 format, unified JSON export) - ⚡ Quick fetch mode (no config file needed) ### Performance & Reliability @@ -216,16 +217,26 @@ exfig fetch -f FILE_ID -r "Images" -o ./images --format webp --webp-quality 90 Supports all formats (PNG, SVG, PDF, JPEG, WebP), filtering (`--filter`), name conversion (`--name-style`), and dark mode variants (`--dark-mode-suffix`). Run `exfig fetch --help` for all options. -### JSON Export (Design Tokens) +### Design Tokens -Export Figma data as [W3C Design Tokens](https://design-tokens.github.io/community-group/format/): +Export Figma data as [W3C Design Tokens](https://design-tokens.github.io/community-group/format/) (DTCG v2025 format): ```bash +# Export from Figma API exfig download colors -o tokens/colors.json exfig download icons -o tokens/icons.json --asset-format svg +exfig download tokens -o tokens/design-tokens.json # Unified (colors + typography + dimensions + numbers) exfig download all -o ./tokens/ + +# Work with local .tokens.json files (no Figma token needed) +exfig tokens info ./tokens.json # Inspect token file +exfig tokens convert ./tokens.json -o out.json # Re-export (filter/transform) +exfig tokens convert ./tokens.json --group "Brand" --type color -o brand-colors.json ``` +Use `--w3c-version v1` for the legacy hex-string format. Colors entries also support `tokensFile` to import from a local +`.tokens.json` file (e.g., from Tokens Studio) without a Figma token — see [CONFIG.md](CONFIG.md). + ### Version Tracking Skip unchanged exports using Figma file version tracking: diff --git a/Sources/ExFigCLI/CLAUDE.md b/Sources/ExFigCLI/CLAUDE.md index 5d62e65d..f16817e7 100644 --- a/Sources/ExFigCLI/CLAUDE.md +++ b/Sources/ExFigCLI/CLAUDE.md @@ -22,7 +22,7 @@ exfig typography → ExportTypography exfig init → GenerateConfigFile exfig schemas → ExtractSchemas exfig fetch → FetchImages -exfig download {colors|icons|images|typography|all} → Download (nested) +exfig download {colors|icons|images|typography|tokens|all} → Download (nested) exfig batch → Batch ``` @@ -161,6 +161,10 @@ Converter factories (`WebpConverterFactory`, `HeicConverterFactory`) handle plat | `Pipeline/SharedDownloadQueue.swift` | Cross-config download pipelining actor | | `Output/FileWriter.swift` | Sequential and parallel file writing with directory creation | | `Shared/ComponentPreFetcher.swift` | Pre-fetch components for multi-entry exports | +| `Input/TokensFileSource.swift` | W3C DTCG .tokens.json parser (local file → ExFigCore models) | +| `Output/W3CTokensExporter.swift` | W3C design token JSON exporter (v1/v2025 formats) | +| `Loaders/NumberVariablesLoader.swift` | Figma number variables → dimension/number tokens | +| `Subcommands/DownloadTokens.swift` | Unified `download tokens` subcommand | ## Modification Patterns diff --git a/Sources/ExFigCLI/Context/ColorsExportContextImpl.swift b/Sources/ExFigCLI/Context/ColorsExportContextImpl.swift index 10876d9e..2ae763a9 100644 --- a/Sources/ExFigCLI/Context/ColorsExportContextImpl.swift +++ b/Sources/ExFigCLI/Context/ColorsExportContextImpl.swift @@ -56,6 +56,38 @@ struct ColorsExportContextImpl: ColorsExportContext { // MARK: - ColorsExportContext func loadColors(from source: ColorsSourceInput) async throws -> ColorsLoadOutput { + if let tokensFilePath = source.tokensFilePath { + // Warn if mode-related fields are configured but will be ignored + if source.darkModeName != nil || source.lightHCModeName != nil || source.darkHCModeName != nil { + ui.warning( + "Local tokens file provides single-mode colors only" + + " — darkModeName/lightHCModeName/darkHCModeName will be ignored" + ) + } + return try loadColorsFromTokensFile(path: tokensFilePath, groupFilter: source.tokensFileGroupFilter) + } + return try await loadColorsFromFigma(source: source) + } + + private func loadColorsFromTokensFile(path: String, groupFilter: String?) throws -> ColorsLoadOutput { + var source = try TokensFileSource.parse(fileAt: path) + try source.resolveAliases() + + for warning in source.warnings { + ui.warning(warning) + } + + var colors = source.toColors() + + if let groupFilter { + let prefix = groupFilter.replacingOccurrences(of: ".", with: "/") + "/" + colors = colors.filter { $0.name.hasPrefix(prefix) } + } + + return ColorsLoadOutput(light: colors) + } + + private func loadColorsFromFigma(source: ColorsSourceInput) async throws -> ColorsLoadOutput { let variableParams = Common.VariablesColors( tokensFileId: source.tokensFileId, tokensCollectionName: source.tokensCollectionName, diff --git a/Sources/ExFigCLI/ExFig.docc/Configuration.md b/Sources/ExFigCLI/ExFig.docc/Configuration.md index 64c57a1b..058f2f4e 100644 --- a/Sources/ExFigCLI/ExFig.docc/Configuration.md +++ b/Sources/ExFigCLI/ExFig.docc/Configuration.md @@ -103,6 +103,33 @@ common = new Common.CommonConfig { } ``` +### Tokens File Source + +Use a local W3C DTCG `.tokens.json` file instead of the Figma Variables API: + +```pkl +import ".exfig/schemas/Common.pkl" +import ".exfig/schemas/iOS.pkl" + +ios = new iOS.iOSConfig { + colors = new iOS.ColorsEntry { + // Load colors from a local .tokens.json file + tokensFile = new Common.TokensFile { + // Path to the .tokens.json file + path = "./design-tokens/colors.tokens.json" + + // Optional: filter to specific token group + groupFilter = "Brand.Colors" + } + + assetsFolder = "Colors" + nameStyle = "camelCase" + } +} +``` + +> When `tokensFile` is set, ExFig reads color tokens from the local file and does not require `FIGMA_PERSONAL_TOKEN` or Figma Variables configuration (`tokensFileId`, `tokensCollectionName`, `lightModeName`). + ### Icons ```pkl diff --git a/Sources/ExFigCLI/ExFig.docc/ExFig.md b/Sources/ExFigCLI/ExFig.docc/ExFig.md index 75d05b3d..92c04ef4 100644 --- a/Sources/ExFigCLI/ExFig.docc/ExFig.md +++ b/Sources/ExFigCLI/ExFig.docc/ExFig.md @@ -10,6 +10,7 @@ ExFig automates the export of design tokens from Figma to native platform resour - **Icons**: Export vector icons as PDF/SVG (iOS), VectorDrawable (Android), or SVG (Flutter) - **Images**: Export raster images with multi-scale support for all platforms - **Typography**: Export text styles as Swift extensions, XML styles, or Dart constants +- **Design Tokens**: Export unified W3C DTCG design tokens (colors, typography, dimensions, numbers) as JSON ## Topics diff --git a/Sources/ExFigCLI/ExFig.docc/Usage.md b/Sources/ExFigCLI/ExFig.docc/Usage.md index c4dc592c..be9ca515 100644 --- a/Sources/ExFigCLI/ExFig.docc/Usage.md +++ b/Sources/ExFigCLI/ExFig.docc/Usage.md @@ -203,6 +203,9 @@ exfig download colors -o debug/colors.json --format raw # Export icons with SVG URLs exfig download icons -o tokens/icons.json --asset-format svg +# Export unified design tokens (colors + typography + dimensions + numbers) +exfig download tokens -o tokens/design-tokens.json + # Export all token types exfig download all -o ./tokens/ ``` @@ -211,21 +214,23 @@ exfig download all -o ./tokens/ | Subcommand | Description | | ------------ | ------------------------------- | -| `colors` | Export colors as JSON | -| `icons` | Export icon metadata with URLs | -| `images` | Export image metadata with URLs | -| `typography` | Export text styles as JSON | -| `all` | Export all types to a directory | +| `colors` | Export colors as JSON | +| `icons` | Export icon metadata with URLs | +| `images` | Export image metadata with URLs | +| `typography` | Export text styles as JSON | +| `tokens` | Export unified design tokens (colors, typography, dimensions, numbers) | +| `all` | Export all types to a directory | ### Download Options | Option | Short | Description | Default | | ---------------- | ----- | -------------------------------- | ------- | -| `--output` | `-o` | Output file path (required) | - | +| `--output` | `-o` | Output file path | varies | | `--format` | `-f` | Output format: w3c, raw | w3c | | `--compact` | - | Output minified JSON | false | | `--asset-format` | - | Image format: svg, png, pdf, jpg | svg | | `--scale` | - | Scale for raster formats | 3 | +| `--w3c-version` | - | W3C format version: v1, v2025 | v2025 | ## Quick Fetch diff --git a/Sources/ExFigCLI/ExFigCommand.swift b/Sources/ExFigCLI/ExFigCommand.swift index db3a8829..ba01c591 100644 --- a/Sources/ExFigCLI/ExFigCommand.swift +++ b/Sources/ExFigCLI/ExFigCommand.swift @@ -91,6 +91,7 @@ struct ExFigCommand: AsyncParsableCommand { ExtractSchemas.self, FetchImages.self, Download.self, + Tokens.self, Batch.self, ], defaultSubcommand: ExportColors.self diff --git a/Sources/ExFigCLI/Input/TokensFileSource.swift b/Sources/ExFigCLI/Input/TokensFileSource.swift new file mode 100644 index 00000000..1362bb6e --- /dev/null +++ b/Sources/ExFigCLI/Input/TokensFileSource.swift @@ -0,0 +1,664 @@ +// swiftlint:disable file_length + +import ExFigCore +import Foundation + +/// Errors during .tokens.json parsing. +enum TokensFileError: LocalizedError { + case fileNotFound(String) + case malformedJSON(String) + case missingValue(tokenPath: String) + case invalidColorObject(tokenPath: String, detail: String) + case invalidDimensionObject(tokenPath: String, detail: String) + case circularAlias(tokenPath: String, chain: [String]) + case unresolvedAlias(tokenPath: String, reference: String) + + var errorDescription: String? { + switch self { + case let .fileNotFound(path): + "Token file not found: \(path)" + case let .malformedJSON(detail): + "Malformed JSON in token file: \(detail)" + case let .missingValue(tokenPath): + "Token missing $value: \(tokenPath)" + case let .invalidColorObject(tokenPath, detail): + "Invalid color object at \(tokenPath): \(detail)" + case let .invalidDimensionObject(tokenPath, detail): + "Invalid dimension object at \(tokenPath): \(detail)" + case let .circularAlias(tokenPath, chain): + "Circular alias at \(tokenPath): \(chain.joined(separator: " → "))" + case let .unresolvedAlias(tokenPath, reference): + "Unresolved alias at \(tokenPath): \(reference)" + } + } +} + +/// A parsed token from a .tokens.json file. +struct ParsedToken { + let path: String + let type: String? + let value: ParsedTokenValue + let description: String? + let deprecated: DeprecatedValue? + let extensions: JSONValue? + + /// `$deprecated` can be boolean or string. + enum DeprecatedValue { + case flag(Bool) + case message(String) + } +} + +/// Possible parsed token values. +enum ParsedTokenValue { + case color(ColorValue) + case dimension(DimensionValue) + case number(Double) + case fontFamily([String]) + case typography(TypographyValue) + case alias(String) + case string(String) + case unknown + + struct ColorValue { + let colorSpace: String + let components: [Double] + let alpha: Double + let hex: String? + } + + struct DimensionValue { + let value: Double + let unit: String + } + + struct TypographyValue { + let fontFamily: [String] + let fontSize: DimensionValue? + let fontWeight: Double? + let lineHeight: Double? + let letterSpacing: DimensionValue? + } +} + +// MARK: - TokensFileSource Parser + +/// Parses W3C DTCG .tokens.json files into typed token models. +/// +/// Supports nested groups, `$type` inheritance, alias resolution, +/// `$root`, and `$deprecated`. +/// +/// Note: `$extends` is not currently implemented (requires cross-file merge). +struct TokensFileSource { + /// All parsed tokens indexed by dot-path (e.g., "Brand.Primary"). + private(set) var tokens: [String: ParsedToken] = [:] + /// Warnings emitted during parsing (unsupported types, non-sRGB colors). + private(set) var warnings: [String] = [] + /// Whether `resolveAliases()` has been called. + private(set) var isResolved = false + /// Number of alias tokens found during `resolveAliases()`. + private(set) var aliasCount = 0 + + /// Unsupported W3C token types that emit warnings. + private static let unsupportedTypes: Set = [ + "cubicBezier", "gradient", "strokeStyle", "border", + "transition", "shadow", "duration", + ] + + // MARK: - Public API + + /// Parse a .tokens.json file at the given path. + static func parse(fileAt path: String) throws -> TokensFileSource { + let url = URL(fileURLWithPath: path) + guard FileManager.default.fileExists(atPath: path) else { + throw TokensFileError.fileNotFound(path) + } + let data = try Data(contentsOf: url) + return try parse(data: data) + } + + /// Parse JSON data as a W3C DTCG token document. + static func parse(data: Data) throws -> TokensFileSource { + let json: JSONValue + do { + json = try JSONCodec.parseValue(from: data) + } catch { + throw TokensFileError.malformedJSON("\(error)") + } + guard json.object != nil else { + throw TokensFileError.malformedJSON("Root must be a JSON object") + } + var source = TokensFileSource() + source.parseGroup(json: json, path: [], inheritedType: nil) + return source + } + + // MARK: - Group Parsing + + private mutating func parseGroup( + json: JSONValue, + path: [String], + inheritedType: String? + ) { + let groupType = json["$type"]?.string ?? inheritedType + let groupDeprecated = parseDeprecated(json["$deprecated"]) + + // $extends is not currently implemented — requires cross-file merge. + if json["$extends"] != nil { + warnings + .append( + "$extends is not yet supported at \(path.joined(separator: ".")) — referenced tokens may be missing" + ) + } + + // Handle group-as-token (group that also has its own $value) + if json["$value"] != nil, !path.isEmpty { + // This group itself is also a token (rare but valid) + parseToken(json: json, path: path, inheritedType: groupType, groupDeprecated: groupDeprecated) + } + + if let rootValue = json["$root"], rootValue["$value"] != nil { + let rootPath = path + ["$root"] + parseToken(json: rootValue, path: rootPath, inheritedType: groupType, groupDeprecated: groupDeprecated) + } + + // Iterate non-$ keys as child groups or tokens + guard let obj = json.object else { return } + for (key, value) in obj where !key.hasPrefix("$") { + guard value.object != nil else { continue } + + let childPath = path + [key] + + if value["$value"] != nil { + parseToken(json: value, path: childPath, inheritedType: groupType, groupDeprecated: groupDeprecated) + } else { + parseGroup(json: value, path: childPath, inheritedType: groupType) + } + } + } + + // MARK: - Token Parsing + + private mutating func parseToken( + json: JSONValue, + path: [String], + inheritedType: String?, + groupDeprecated: ParsedToken.DeprecatedValue? + ) { + let tokenPath = path.joined(separator: ".") + let type = json["$type"]?.string ?? inheritedType + let description = json["$description"]?.string + let deprecated = parseDeprecated(json["$deprecated"]) ?? groupDeprecated + + // Check for unsupported types + if let type, Self.unsupportedTypes.contains(type) { + warnings.append("Unsupported token type '\(type)' at \(tokenPath) — skipped") + return + } + + guard let rawValue = json["$value"] else { + warnings.append("Token missing $value at \(tokenPath)") + return + } + + let value = parseValue(rawValue, type: type, tokenPath: tokenPath) + + tokens[tokenPath] = ParsedToken( + path: tokenPath, + type: type, + value: value, + description: description, + deprecated: deprecated, + extensions: json["$extensions"] + ) + } + + // MARK: - Value Parsing + + private mutating func parseValue(_ rawValue: JSONValue, type: String?, tokenPath: String) -> ParsedTokenValue { + // Alias reference: string starting with "{" + if let str = rawValue.string, str.hasPrefix("{"), str.hasSuffix("}") { + let reference = String(str.dropFirst().dropLast()) + return .alias(reference) + } + + switch type { + case "color": + return parseColorValue(rawValue, tokenPath: tokenPath) + case "dimension": + return parseDimensionValue(rawValue, tokenPath: tokenPath) + case "number": + return parseNumberValue(rawValue, tokenPath: tokenPath) + case "fontFamily": + return parseFontFamilyValue(rawValue, tokenPath: tokenPath) + case "typography": + return parseTypographyValue(rawValue, tokenPath: tokenPath) + default: + return inferValueType(rawValue, tokenPath: tokenPath) + } + } + + private mutating func parseNumberValue(_ rawValue: JSONValue, tokenPath: String) -> ParsedTokenValue { + if let num = rawValue.number { return .number(num) } + warnings.append("Expected number value at \(tokenPath)") + return .unknown + } + + private mutating func inferValueType(_ rawValue: JSONValue, tokenPath: String) -> ParsedTokenValue { + if rawValue["colorSpace"] != nil { + return parseColorValue(rawValue, tokenPath: tokenPath) + } + if rawValue["value"] != nil, rawValue["unit"] != nil { + return parseDimensionValue(rawValue, tokenPath: tokenPath) + } + if let num = rawValue.number { return .number(num) } + if let str = rawValue.string { return .string(str) } + return .unknown + } + + // MARK: - Color Parsing + + private mutating func parseColorValue(_ rawValue: JSONValue, tokenPath: String) -> ParsedTokenValue { + guard rawValue.object != nil else { + // Legacy hex string fallback + if let hex = rawValue.string { + if let color = hexToColorValue(hex) { return .color(color) } + } + warnings.append("Invalid color value at \(tokenPath)") + return .unknown + } + + guard let colorSpace = rawValue["colorSpace"]?.string else { + warnings.append("Missing colorSpace at \(tokenPath)") + return .unknown + } + + guard let components = rawValue["components"]?.array, + components.count >= 3 + else { + warnings.append("Invalid components at \(tokenPath)") + return .unknown + } + + var rgb: [Double] = [] + for (i, comp) in components.prefix(3).enumerated() { + if let num = comp.number { + rgb.append(num) + } else { + warnings.append("Non-numeric color component at index \(i) in \(tokenPath), defaulting to 0") + rgb.append(0) + } + } + let alpha = rawValue["alpha"]?.number ?? 1.0 + let hex = rawValue["hex"]?.string + + // non-sRGB color space warning + if colorSpace != "srgb" { + warnings.append("Non-sRGB color space '\(colorSpace)' at \(tokenPath) — values used as-is") + } + + return .color(ParsedTokenValue.ColorValue( + colorSpace: colorSpace, + components: rgb, + alpha: alpha, + hex: hex + )) + } + + private func hexToColorValue(_ hex: String) -> ParsedTokenValue.ColorValue? { + var cleanHex = hex + if cleanHex.hasPrefix("#") { cleanHex = String(cleanHex.dropFirst()) } + guard cleanHex.count == 6 || cleanHex.count == 8 else { return nil } + + guard let hexNum = UInt64(cleanHex, radix: 16) else { return nil } + + let r: Double + let g: Double + let b: Double + let a: Double + + if cleanHex.count == 8 { + r = Double((hexNum >> 24) & 0xFF) / 255 + g = Double((hexNum >> 16) & 0xFF) / 255 + b = Double((hexNum >> 8) & 0xFF) / 255 + a = Double(hexNum & 0xFF) / 255 + } else { + r = Double((hexNum >> 16) & 0xFF) / 255 + g = Double((hexNum >> 8) & 0xFF) / 255 + b = Double(hexNum & 0xFF) / 255 + a = 1.0 + } + + return ParsedTokenValue.ColorValue(colorSpace: "srgb", components: [r, g, b], alpha: a, hex: hex) + } + + // MARK: - Dimension Parsing + + private mutating func parseDimensionValue(_ rawValue: JSONValue, tokenPath: String) -> ParsedTokenValue { + guard rawValue.object != nil else { + warnings.append("Dimension $value must be an object at \(tokenPath)") + return .unknown + } + + guard let numericValue = rawValue["value"]?.number else { + warnings.append("Missing numeric 'value' in dimension at \(tokenPath)") + return .unknown + } + + guard let unit = rawValue["unit"]?.string else { + warnings.append("Missing 'unit' in dimension at \(tokenPath)") + return .unknown + } + + return .dimension(ParsedTokenValue.DimensionValue(value: numericValue, unit: unit)) + } + + // MARK: - Typography Parsing + + private mutating func parseTypographyValue(_ rawValue: JSONValue, tokenPath: String) -> ParsedTokenValue { + guard rawValue.object != nil else { + warnings.append("Typography $value must be an object at \(tokenPath)") + return .unknown + } + + var fontFamily: [String] = [] + if let arr = rawValue["fontFamily"]?.array { + fontFamily = arr.compactMap(\.string) + if fontFamily.count != arr.count { + let dropped = arr.count - fontFamily.count + warnings.append( + "fontFamily array at \(tokenPath) contains \(dropped) non-string entries that were dropped" + ) + } + } else if let str = rawValue["fontFamily"]?.string { + fontFamily = [str] + } + + var fontSize: ParsedTokenValue.DimensionValue? + if let fsObj = rawValue["fontSize"], + let v = fsObj["value"]?.number, + let u = fsObj["unit"]?.string + { + fontSize = ParsedTokenValue.DimensionValue(value: v, unit: u) + } + + var fontWeight: Double? + if let w = rawValue["fontWeight"]?.number { + fontWeight = w + } else if let w = rawValue["fontWeight"]?.string { + fontWeight = Self.fontWeightFromString(w) + } + + let lineHeight = rawValue["lineHeight"]?.number + + var letterSpacing: ParsedTokenValue.DimensionValue? + if let lsObj = rawValue["letterSpacing"], + let v = lsObj["value"]?.number, + let u = lsObj["unit"]?.string + { + letterSpacing = ParsedTokenValue.DimensionValue(value: v, unit: u) + } + + return .typography(ParsedTokenValue.TypographyValue( + fontFamily: fontFamily, + fontSize: fontSize, + fontWeight: fontWeight, + lineHeight: lineHeight, + letterSpacing: letterSpacing + )) + } + + // MARK: - Font Family Parsing + + private mutating func parseFontFamilyValue(_ rawValue: JSONValue, tokenPath: String) -> ParsedTokenValue { + if let arr = rawValue.array { + let families = arr.compactMap(\.string) + if families.count != arr.count { + let dropped = arr.count - families.count + warnings.append( + "fontFamily array at \(tokenPath) contains \(dropped) non-string entries that were dropped" + ) + } + return .fontFamily(families) + } + if let str = rawValue.string { return .fontFamily([str]) } + warnings.append("Invalid fontFamily value at \(tokenPath)") + return .unknown + } + + // MARK: - Font Weight Mapping + + private static let fontWeightMap: [String: Double] = [ + "thin": 100, "hairline": 100, + "extralight": 200, "ultralight": 200, "extra-light": 200, "ultra-light": 200, + "light": 300, + "normal": 400, "regular": 400, "book": 400, + "medium": 500, + "semibold": 600, "demibold": 600, "semi-bold": 600, "demi-bold": 600, + "bold": 700, + "extrabold": 800, "ultrabold": 800, "extra-bold": 800, "ultra-bold": 800, + "black": 900, "heavy": 900, + "extrablack": 950, "ultrablack": 950, "extra-black": 950, "ultra-black": 950, + ] + + static func fontWeightFromString(_ name: String) -> Double? { + fontWeightMap[name.lowercased()] + } + + // MARK: - $deprecated Parsing + + private mutating func parseDeprecated(_ value: JSONValue?) -> ParsedToken.DeprecatedValue? { + guard let value else { return nil } + if let bool = value.bool { return .flag(bool) } + if let str = value.string { return .message(str) } + warnings.append("Unexpected $deprecated value type (expected boolean or string)") + return nil + } +} + +// MARK: - Alias Resolution + +extension TokensFileSource { + /// Resolves all aliases in the parsed tokens, detecting circular references. + mutating func resolveAliases() throws { + var resolved: Set = [] + var resolving: Set = [] + + // Count aliases before resolution + aliasCount = tokens.values.filter { + if case .alias = $0.value { return true } + return false + }.count + + for path in tokens.keys { + try resolveAlias(path: path, resolved: &resolved, resolving: &resolving, chain: []) + } + + // Warn about aliases resolved to .unknown values + let unknownAfterResolve = tokens.filter { + if case .unknown = $0.value.value { return true } + return false + } + if !unknownAfterResolve.isEmpty { + let names = unknownAfterResolve.keys.sorted().prefix(5).joined(separator: ", ") + let more = unknownAfterResolve.count > 5 ? ", +\(unknownAfterResolve.count - 5) more" : "" + let count = unknownAfterResolve.count + warnings.append( + "\(count) token(s) have unparseable values and will be excluded from export: \(names)\(more)" + ) + } + + isResolved = true + } + + private mutating func resolveAlias( + path: String, + resolved: inout Set, + resolving: inout Set, + chain: [String] + ) throws { + guard !resolved.contains(path) else { return } + + guard case let .alias(reference) = tokens[path]?.value else { + resolved.insert(path) + return + } + + if resolving.contains(path) { + throw TokensFileError.circularAlias(tokenPath: path, chain: chain + [path]) + } + + resolving.insert(path) + + guard var target = tokens[reference] else { + throw TokensFileError.unresolvedAlias(tokenPath: path, reference: reference) + } + + // Recursively resolve the target first + try resolveAlias(path: reference, resolved: &resolved, resolving: &resolving, chain: chain + [path]) + + // Copy resolved value, preserving original token metadata + if let resolvedTarget = tokens[reference] { + target = resolvedTarget + } + + tokens[path] = ParsedToken( + path: path, + type: tokens[path]?.type ?? target.type, + value: target.value, + description: tokens[path]?.description ?? target.description, + deprecated: tokens[path]?.deprecated ?? target.deprecated, + extensions: tokens[path]?.extensions + ) + + resolving.remove(path) + resolved.insert(path) + } +} + +// MARK: - Model Mapping + +extension TokensFileSource { + /// Converts parsed color tokens to ExFigCore Color models. + /// - Precondition: `resolveAliases()` must be called first. + func toColors() -> [Color] { + assert(isResolved, "resolveAliases() must be called before toColors()") + return tokens.compactMap { path, token -> Color? in + guard case let .color(colorValue) = token.value else { return nil } + guard colorValue.components.count >= 3 else { return nil } + + return Color( + name: path.replacingOccurrences(of: ".", with: "/"), + platform: nil, + red: colorValue.components[0], + green: colorValue.components[1], + blue: colorValue.components[2], + alpha: colorValue.alpha + ) + } + } + + /// Converts parsed typography tokens to ExFigCore TextStyle models. + /// - Precondition: `resolveAliases()` must be called first. + func toTextStyles() -> [TextStyle] { + assert(isResolved, "resolveAliases() must be called before toTextStyles()") + return tokens.compactMap { path, token -> TextStyle? in + guard case let .typography(typo) = token.value else { return nil } + guard !typo.fontFamily.isEmpty else { return nil } + + // Default to 16px when fontSize is omitted (common in partial typography tokens) + let fontSize = typo.fontSize?.value ?? 16 + + return TextStyle( + name: path.replacingOccurrences(of: ".", with: "/"), + fontName: typo.fontFamily[0], + fontSize: fontSize, + fontStyle: nil, + lineHeight: typo.lineHeight, + letterSpacing: typo.letterSpacing?.value ?? 0, + textCase: .original + ) + } + } + + /// Converts parsed dimension tokens to NumberToken models. + /// - Precondition: `resolveAliases()` must be called first. + func toDimensionTokens() -> [NumberToken] { + assert(isResolved, "resolveAliases() must be called before toDimensionTokens()") + return tokens.compactMap { path, token -> NumberToken? in + guard case let .dimension(dim) = token.value else { return nil } + + return NumberToken( + name: path.replacingOccurrences(of: ".", with: "/"), + value: dim.value, + tokenType: .dimension, + description: token.description, + variableId: nil, + fileId: nil + ) + } + } + + /// Converts parsed number tokens to NumberToken models. + /// - Precondition: `resolveAliases()` must be called first. + func toNumberTokens() -> [NumberToken] { + assert(isResolved, "resolveAliases() must be called before toNumberTokens()") + return tokens.compactMap { path, token -> NumberToken? in + guard case let .number(num) = token.value else { return nil } + + return NumberToken( + name: path.replacingOccurrences(of: ".", with: "/"), + value: num, + tokenType: .number, + description: token.description, + variableId: nil, + fileId: nil + ) + } + } +} + +// MARK: - Filtering & Statistics + +extension TokensFileSource { + /// Creates a new source containing only tokens whose path starts with the given group prefix. + func filteredByGroup(_ group: String) -> TokensFileSource { + let prefix = group + "." + var copy = self + copy.tokens = tokens.filter { $0.key.hasPrefix(prefix) || $0.key == group } + return copy + } + + /// Creates a new source containing only tokens matching the given type string(s). + func filteredByTypes(_ types: Set) -> TokensFileSource { + var copy = self + copy.tokens = tokens.filter { _, token in + guard let type = token.type else { return false } + return types.contains(type) + } + return copy + } + + /// Returns token counts grouped by type, sorted descending by count. + func tokenCountsByType() -> [(type: String, count: Int)] { + var counts: [String: Int] = [:] + for (_, token) in tokens { + let type = token.type ?? "unknown" + counts[type, default: 0] += 1 + } + return counts.sorted { $0.value > $1.value }.map { (type: $0.key, count: $0.value) } + } + + /// Returns top-level group names with their total token counts, sorted descending. + func topLevelGroups() -> [(name: String, count: Int)] { + var groups: [String: Int] = [:] + for path in tokens.keys { + let firstComponent = path.split(separator: ".").first.map(String.init) ?? path + groups[firstComponent, default: 0] += 1 + } + return groups.sorted { $0.value != $1.value ? $0.value > $1.value : $0.key < $1.key } + .map { (name: $0.key, count: $0.value) } + } +} + +// swiftlint:enable file_length diff --git a/Sources/ExFigCLI/Loaders/Colors/ColorsVariablesLoader.swift b/Sources/ExFigCLI/Loaders/Colors/ColorsVariablesLoader.swift index a81d2eb4..d15bbf9b 100644 --- a/Sources/ExFigCLI/Loaders/Colors/ColorsVariablesLoader.swift +++ b/Sources/ExFigCLI/Loaders/Colors/ColorsVariablesLoader.swift @@ -17,9 +17,16 @@ final class ColorsVariablesLoader: Sendable { self.filter = filter } + /// Per-color-per-mode alias paths: colorName → mode key → referenced variable name. + /// Mode keys: "light", "dark", "lightHC", "darkHC". + typealias ColorAliases = [String: [String: String]] + struct LoadResult: Sendable { let output: ColorsLoaderOutput let warnings: [ExFigWarning] + let aliases: ColorAliases + let descriptions: [String: String] + let metadata: [String: ColorTokenMetadata] } func load() async throws -> LoadResult { @@ -33,18 +40,44 @@ final class ColorsVariablesLoader: Sendable { guard let tokenCollection = meta.variableCollections.first(where: { $0.value.name == tokensCollectionName }) else { throw ExFigError.custom(errorString: "tokensCollectionName not found") } + let modeIds = extractModeIds(from: tokenCollection.value) + + var descriptions: [String: String] = [:] + var tokenMetadata: [String: ColorTokenMetadata] = [:] + let variables: [Variable] = tokenCollection.value.variableIds.compactMap { tokenId in guard let variableMeta = meta.variables[tokenId] else { return nil } guard variableMeta.deletedButReferenced != true else { return nil } - return mapVariableMetaToVariable( - variableMeta: variableMeta, - modeIds: extractModeIds(from: tokenCollection.value) + + // Collect description and metadata + let desc = variableMeta.description.trimmingCharacters(in: .whitespacesAndNewlines) + if !desc.isEmpty { + descriptions[variableMeta.name] = desc + } + tokenMetadata[variableMeta.name] = ColorTokenMetadata( + variableId: variableMeta.id, + fileId: tokensFileId ) + + return mapVariableMetaToVariable(variableMeta: variableMeta, modeIds: modeIds) } var warnings: [ExFigWarning] = [] - let output = mapVariablesToColorOutput(variables: variables, meta: meta, warnings: &warnings) - return LoadResult(output: output, warnings: warnings) + var aliases: ColorAliases = [:] + let output = mapVariablesToColorOutput( + variables: variables, + meta: meta, + warnings: &warnings, + aliases: &aliases + ) + + return LoadResult( + output: output, + warnings: warnings, + aliases: aliases, + descriptions: descriptions, + metadata: tokenMetadata + ) } private func loadVariables(fileId: String) async throws -> VariablesEndpoint.Content { @@ -84,10 +117,13 @@ final class ColorsVariablesLoader: Sendable { return Variable(name: variableMeta.name, description: variableMeta.description, valuesByMode: values) } + // swiftlint:disable function_parameter_count + private func mapVariablesToColorOutput( variables: [Variable], meta: VariablesEndpoint.Content, - warnings: inout [ExFigWarning] + warnings: inout [ExFigWarning], + aliases: inout ColorAliases ) -> ColorsLoaderOutput { var colorOutput = Colors() for variable in variables { @@ -95,47 +131,62 @@ final class ColorsVariablesLoader: Sendable { variable: variable, mode: variable.valuesByMode.light, colorsArray: &colorOutput.lightColors, + modeKey: "light", filter: filter, meta: meta, - warnings: &warnings + warnings: &warnings, + aliases: &aliases ) handleColorMode( variable: variable, mode: variable.valuesByMode.dark, colorsArray: &colorOutput.darkColors, + modeKey: "dark", filter: filter, meta: meta, - warnings: &warnings + warnings: &warnings, + aliases: &aliases ) handleColorMode( variable: variable, mode: variable.valuesByMode.lightHC, colorsArray: &colorOutput.lightHCColors, + modeKey: "lightHC", filter: filter, meta: meta, - warnings: &warnings + warnings: &warnings, + aliases: &aliases ) handleColorMode( variable: variable, mode: variable.valuesByMode.darkHC, colorsArray: &colorOutput.darkHCColors, + modeKey: "darkHC", filter: filter, meta: meta, - warnings: &warnings + warnings: &warnings, + aliases: &aliases ) } return (colorOutput.lightColors, colorOutput.darkColors, colorOutput.lightHCColors, colorOutput.darkHCColors) } - // swiftlint:disable:next function_parameter_count private func handleColorMode( variable: Variable, mode: ValuesByMode?, colorsArray: inout [Color], + modeKey: String, filter: String?, meta: VariablesEndpoint.Content, - warnings: inout [ExFigWarning] + warnings: inout [ExFigWarning], + aliases: inout ColorAliases, + depth: Int = 0 ) { + guard depth < 10 else { + warnings.append(.circularColorAlias(tokenName: variable.name)) + return + } + if case let .color(color) = mode, doesColorMatchFilter(from: variable) { colorsArray.append(createColor(from: variable, color: color)) } else if case let .variableAlias(variableAlias) = mode, @@ -149,6 +200,10 @@ final class ColorsVariablesLoader: Sendable { )) return } + + // Record the alias path (referenced variable name) + aliases[variable.name, default: [:]][modeKey] = variableMeta.name + let modeId = variableCollectionId.modes.first(where: { $0.name == variableParams?.primitivesModeName })?.modeId ?? variableCollectionId.defaultModeId @@ -156,13 +211,18 @@ final class ColorsVariablesLoader: Sendable { variable: variable, mode: variableMeta.valuesByMode[modeId], colorsArray: &colorsArray, + modeKey: modeKey, filter: filter, meta: meta, - warnings: &warnings + warnings: &warnings, + aliases: &aliases, + depth: depth + 1 ) } } + // swiftlint:enable function_parameter_count + private func doesColorMatchFilter(from variable: Variable) -> Bool { guard let filter else { return true } let assetsFilter = AssetsFilter(filter: filter) diff --git a/Sources/ExFigCLI/Loaders/NumberVariablesLoader.swift b/Sources/ExFigCLI/Loaders/NumberVariablesLoader.swift new file mode 100644 index 00000000..1cbadc46 --- /dev/null +++ b/Sources/ExFigCLI/Loaders/NumberVariablesLoader.swift @@ -0,0 +1,203 @@ +import ExFigCore +import FigmaAPI + +/// W3C token type for a numeric variable, determined by Figma variable scopes. +public enum NumberTokenType: String, Sendable { + /// Spatial value with unit — `$value: {"value": N, "unit": "px"}`. + case dimension + /// Unitless numeric value — `$value: N`. + case number +} + +/// A loaded numeric token with its resolved W3C type. +public struct NumberToken: Sendable { + public let name: String + public let value: Double + public let tokenType: NumberTokenType + public let description: String? + public let variableId: String? + public let fileId: String? +} + +/// Loads FLOAT variables from Figma and classifies them as `dimension` or `number` +/// based on their Figma scopes. +final class NumberVariablesLoader: Sendable { + private let client: Client + private let tokensFileId: String + private let tokensCollectionName: String + private let modeName: String + private let filter: String? + + init( + client: Client, + tokensFileId: String, + tokensCollectionName: String, + modeName: String = "Default", + filter: String? = nil + ) { + self.client = client + self.tokensFileId = tokensFileId + self.tokensCollectionName = tokensCollectionName + self.modeName = modeName + self.filter = filter + } + + struct LoadResult: Sendable { + let dimensions: [NumberToken] + let numbers: [NumberToken] + let warnings: [ExFigWarning] + } + + func load() async throws -> LoadResult { + let endpoint = VariablesEndpoint(fileId: tokensFileId) + let meta = try await client.request(endpoint) + + guard let collection = meta.variableCollections.first(where: { $0.value.name == tokensCollectionName }) + else { + throw ExFigError.custom(errorString: "Collection '\(tokensCollectionName)' not found for number variables") + } + + let modeId = collection.value.modes.first(where: { $0.name == modeName })?.modeId + ?? collection.value.defaultModeId + + var dimensions: [NumberToken] = [] + var numbers: [NumberToken] = [] + var warnings: [ExFigWarning] = [] + + for variableId in collection.value.variableIds { + guard let variable = meta.variables[variableId] else { continue } + guard isFloatVariable(variable) else { continue } + + if let token = processVariable(variable, modeId: modeId, meta: meta, warnings: &warnings) { + switch token.tokenType { + case .dimension: dimensions.append(token) + case .number: numbers.append(token) + } + } + } + + return LoadResult(dimensions: dimensions, numbers: numbers, warnings: warnings) + } + + private func isFloatVariable(_ variable: VariableValue) -> Bool { + guard variable.deletedButReferenced != true else { return false } + guard variable.resolvedType == "FLOAT" else { return false } + if let filter { + return AssetsFilter(filter: filter).match(name: variable.name) + } + return true + } + + private func processVariable( + _ variable: VariableValue, + modeId: String, + meta: VariablesMeta, + warnings: inout [ExFigWarning] + ) -> NumberToken? { + guard let modeValue = variable.valuesByMode[modeId] else { return nil } + + let result = resolveValue(modeValue, meta: meta, modeId: modeId) + let resolvedValue: Double + switch result { + case let .resolved(value): + resolvedValue = value + case .unresolved: + warnings.append(.unresolvedNumberAlias(tokenName: variable.name)) + return nil + case .depthExceeded: + warnings.append(.depthExceededNumberAlias(tokenName: variable.name)) + return nil + } + + let desc = variable.description.trimmingCharacters(in: .whitespacesAndNewlines) + return NumberToken( + name: variable.name, + value: resolvedValue, + tokenType: Self.scopesToTokenType(variable.scopes ?? []), + description: desc.isEmpty ? nil : desc, + variableId: variable.id, + fileId: tokensFileId + ) + } + + /// Result of resolving a number variable value. + private enum ResolveResult { + case resolved(Double) + case unresolved + case depthExceeded + } + + private func resolveValue( + _ value: ValuesByMode, meta: VariablesMeta, modeId: String + ) -> ResolveResult { + switch value { + case let .number(num): + .resolved(num) + case let .variableAlias(alias): + resolveNumberAlias(alias: alias, meta: meta, modeId: modeId) + default: + .unresolved + } + } + + private func resolveNumberAlias( + alias: VariableAlias, + meta: VariablesMeta, + modeId: String, + depth: Int = 0 + ) -> ResolveResult { + guard depth < 10 else { return .depthExceeded } + guard let variable = meta.variables[alias.id] else { return .unresolved } + guard variable.deletedButReferenced != true else { return .unresolved } + + let collection = meta.variableCollections[variable.variableCollectionId] + let resolvedModeId = collection?.modes.first(where: { $0.name == "Value" })?.modeId + ?? collection?.defaultModeId + ?? modeId + + guard let value = variable.valuesByMode[resolvedModeId] else { return .unresolved } + + switch value { + case let .number(num): + return .resolved(num) + case let .variableAlias(nextAlias): + return resolveNumberAlias(alias: nextAlias, meta: meta, modeId: modeId, depth: depth + 1) + default: + return .unresolved + } + } + + // MARK: - Scope to Token Type Mapping + + /// Figma scopes that indicate a spatial/dimensional value (needs "px" unit). + private static let dimensionScopes: Set = [ + "ALL_SCOPES", + "WIDTH_HEIGHT", + "GAP", + "CORNER_RADIUS", + "FONT_SIZE", + "LINE_HEIGHT", + "LETTER_SPACING", + "STROKE_FLOAT", + "EFFECT_FLOAT", + "PARAGRAPH_INDENT", + "PARAGRAPH_SPACING", + ] + + /// Figma scopes that indicate a unitless numeric value. + private static let numberScopes: Set = [ + "FONT_WEIGHT", + "OPACITY", + ] + + /// Maps Figma variable scopes to W3C token type. + /// + /// If any scope is in `dimensionScopes`, the variable is a `dimension`. + /// Otherwise (number scopes, empty, or unknown) defaults to `number`. + static func scopesToTokenType(_ scopes: [String]) -> NumberTokenType { + if scopes.contains(where: { dimensionScopes.contains($0) }) { + return .dimension + } + return .number + } +} diff --git a/Sources/ExFigCLI/Output/DownloadExportHelpers.swift b/Sources/ExFigCLI/Output/DownloadExportHelpers.swift index 42bbb7be..be0636f4 100644 --- a/Sources/ExFigCLI/Output/DownloadExportHelpers.swift +++ b/Sources/ExFigCLI/Output/DownloadExportHelpers.swift @@ -37,15 +37,23 @@ enum AssetExportHelper { static func exportW3C( components: [NodeId: Component], exportUrls: [NodeId: String], + fileId: String? = nil, outputURL: URL, - compact: Bool + compact: Bool, + w3cVersion: W3CVersion = .v2025 ) throws { let assets = components.compactMap { nodeId, component -> AssetToken? in guard let url = exportUrls[nodeId] else { return nil } - return AssetToken(name: component.name, url: url, description: component.description) + return AssetToken( + name: component.name, + url: url, + description: component.description, + nodeId: component.nodeId, + fileId: fileId + ) } - let exporter = W3CTokensExporter() + let exporter = W3CTokensExporter(version: w3cVersion) let tokens = exporter.exportAssets(assets: assets) let jsonData = try exporter.serializeToJSON(tokens, compact: compact) @@ -160,17 +168,34 @@ enum ColorExportHelper { ) } + /// Mode key to display name mapping used by buildColorsByMode. + static let modeKeyToName: [String: String] = [ + "light": "Light", + "dark": "Dark", + "lightHC": "Contrast Light", + "darkHC": "Contrast Dark", + ] + /// Exports colors to W3C format and writes to file. static func exportW3C( colors: ColorsLoaderOutput, descriptions: [String: String] = [:], + metadata: [String: ColorTokenMetadata] = [:], + aliases: ColorsVariablesLoader.ColorAliases = [:], outputURL: URL, - compact: Bool + compact: Bool, + w3cVersion: W3CVersion = .v2025 ) throws { let colorsByMode = buildColorsByMode(from: colors) - let exporter = W3CTokensExporter() - let tokens = exporter.exportColors(colorsByMode: colorsByMode, descriptions: descriptions) + let exporter = W3CTokensExporter(version: w3cVersion) + let tokens = exporter.exportColors( + colorsByMode: colorsByMode, + descriptions: descriptions, + metadata: metadata, + aliases: aliases, + modeKeyToName: modeKeyToName + ) let jsonData = try exporter.serializeToJSON(tokens, compact: compact) try jsonData.write(to: outputURL) @@ -207,9 +232,10 @@ enum TypographyExportHelper { static func exportW3C( textStyles: [TextStyle], outputURL: URL, - compact: Bool + compact: Bool, + w3cVersion: W3CVersion = .v2025 ) throws { - let exporter = W3CTokensExporter() + let exporter = W3CTokensExporter(version: w3cVersion) let tokens = exporter.exportTypography(textStyles: textStyles) let jsonData = try exporter.serializeToJSON(tokens, compact: compact) diff --git a/Sources/ExFigCLI/Output/W3CTokensExporter.swift b/Sources/ExFigCLI/Output/W3CTokensExporter.swift index 9afeb4da..6bfd1841 100644 --- a/Sources/ExFigCLI/Output/W3CTokensExporter.swift +++ b/Sources/ExFigCLI/Output/W3CTokensExporter.swift @@ -1,29 +1,55 @@ +// swiftlint:disable file_length + +import ArgumentParser import ExFigCore import Foundation +/// W3C Design Tokens spec version for export. +public enum W3CVersion: String, ExpressibleByArgument, CaseIterable, Sendable { + /// Legacy format: hex strings, mode dicts, `$type: "asset"`. + case v1 + /// W3C DTCG v2025.10: color objects, `$extensions`, no invented types. + case v2025 +} + /// Input structure for asset token export. public struct AssetToken: Sendable { public let name: String public let url: String public let description: String? + public let nodeId: String? + public let fileId: String? - public init(name: String, url: String, description: String?) { + public init(name: String, url: String, description: String?, nodeId: String? = nil, fileId: String? = nil) { self.name = name self.url = url self.description = description + self.nodeId = nodeId + self.fileId = fileId } } /// Exports design tokens in W3C Design Tokens format. /// See: https://design-tokens.github.io/community-group/format/ public struct W3CTokensExporter: Sendable { - public init() {} + public let version: W3CVersion + + public init(version: W3CVersion = .v2025) { + self.version = version + } // MARK: - Color Hex Conversion - /// Converts RGBA color components (0.0-1.0) to hex string. - /// Returns #RRGGBB for opaque colors, #RRGGBBAA for colors with transparency. - public func colorToHex(r: Double, g: Double, b: Double, a: Double) -> String { + /// Converts RGB color components (0.0-1.0) to 6-digit hex string (#RRGGBB). + public func colorToHex(r: Double, g: Double, b: Double) -> String { + let red = Int(round(r * 255)) + let green = Int(round(g * 255)) + let blue = Int(round(b * 255)) + return String(format: "#%02x%02x%02x", red, green, blue) + } + + /// Legacy hex conversion with alpha in the string (#RRGGBBAA for transparent colors). + public func colorToHexLegacy(r: Double, g: Double, b: Double, a: Double) -> String { let red = Int(round(r * 255)) let green = Int(round(g * 255)) let blue = Int(round(b * 255)) @@ -36,6 +62,29 @@ public struct W3CTokensExporter: Sendable { } } + // MARK: - Color Object (v2025.10) + + /// Converts RGBA color to a v2025.10 Color Module object. + /// + /// Format: `{"colorSpace": "srgb", "components": [r,g,b], "hex": "#rrggbb"}` + /// Alpha is included as a separate field only when != 1.0. + public func colorToObject(r: Double, g: Double, b: Double, a: Double) -> [String: Any] { + var obj: [String: Any] = [ + "colorSpace": "srgb", + "components": [r, g, b], + "hex": colorToHex(r: r, g: g, b: b), + ] + if a < 1.0 { + obj["alpha"] = a + } + return obj + } + + /// Converts an ExFigCore Color to a v2025.10 color object. + public func colorToObject(_ color: Color) -> [String: Any] { + colorToObject(r: color.red, g: color.green, b: color.blue, a: color.alpha) + } + // MARK: - Name Hierarchy /// Converts a slash-separated name into path components. @@ -51,33 +100,357 @@ public struct W3CTokensExporter: Sendable { /// - Parameters: /// - colorsByMode: Dictionary mapping mode names (e.g., "Light", "Dark") to arrays of colors /// - descriptions: Optional dictionary mapping color names to descriptions + /// - metadata: Optional Figma metadata per color (variableId, fileId) + /// - aliases: Per-color per-mode alias paths (mode key → referenced variable name) + /// - modeKeyToName: Mapping from internal mode keys to display names /// - Returns: Nested dictionary structure representing W3C tokens public func exportColors( colorsByMode: [String: [Color]], - descriptions: [String: String] = [:] + descriptions: [String: String] = [:], + metadata: [String: ColorTokenMetadata] = [:], + aliases: [String: [String: String]] = [:], + modeKeyToName: [String: String] = [:] + ) -> [String: Any] { + switch version { + case .v1: + exportColorsV1(colorsByMode: colorsByMode, descriptions: descriptions) + case .v2025: + exportColorsV2025( + colorsByMode: colorsByMode, + descriptions: descriptions, + metadata: metadata, + aliases: aliases, + modeKeyToName: modeKeyToName + ) + } + } + + // MARK: - Typography Export + + /// Exports text styles to W3C Design Tokens format. + public func exportTypography(textStyles: [TextStyle]) -> [String: Any] { + switch version { + case .v1: + exportTypographyV1(textStyles: textStyles) + case .v2025: + exportTypographyV2025(textStyles: textStyles) + } + } + + // MARK: - Assets Export + + /// Exports assets (icons, images) to W3C Design Tokens format. + public func exportAssets(assets: [AssetToken]) -> [String: Any] { + switch version { + case .v1: + exportAssetsV1(assets: assets) + case .v2025: + exportAssetsV2025(assets: assets) + } + } + + // MARK: - Dimensions Export + + /// Exports dimension tokens to W3C Design Tokens format. + /// Dimension `$value` is an object: `{"value": N, "unit": "px"}`. + public func exportDimensions(tokens: [NumberToken]) -> [String: Any] { + var result: [String: Any] = [:] + for token in tokens { + let path = nameToHierarchy(token.name) + var tokenValue: [String: Any] = [ + "$type": "dimension", + "$value": ["value": token.value, "unit": "px"], + ] + if let description = token.description { + tokenValue["$description"] = description + } + var exfigExt: [String: Any] = [:] + if let variableId = token.variableId { exfigExt["variableId"] = variableId } + if let fileId = token.fileId { exfigExt["fileId"] = fileId } + if !exfigExt.isEmpty { + tokenValue["$extensions"] = ["com.exfig": exfigExt] + } + insertToken(into: &result, path: path, value: tokenValue) + } + return result + } + + // MARK: - Numbers Export + + /// Exports number tokens to W3C Design Tokens format. + /// Number `$value` is a plain JSON number. + public func exportNumbers(tokens: [NumberToken]) -> [String: Any] { + var result: [String: Any] = [:] + for token in tokens { + let path = nameToHierarchy(token.name) + var tokenValue: [String: Any] = [ + "$type": "number", + "$value": token.value, + ] + if let description = token.description { + tokenValue["$description"] = description + } + var exfigExt: [String: Any] = [:] + if let variableId = token.variableId { exfigExt["variableId"] = variableId } + if let fileId = token.fileId { exfigExt["fileId"] = fileId } + if !exfigExt.isEmpty { + tokenValue["$extensions"] = ["com.exfig": exfigExt] + } + insertToken(into: &result, path: path, value: tokenValue) + } + return result + } + + // MARK: - JSON Serialization + + /// Serializes tokens to JSON data. + public func serializeToJSON(_ tokens: [String: Any], compact: Bool) throws -> Data { + let options: JSONSerialization.WritingOptions = compact + ? [.sortedKeys] + : [.prettyPrinted, .sortedKeys] + + return try JSONSerialization.data(withJSONObject: tokens, options: options) + } +} + +// MARK: - Figma Metadata + +/// Figma metadata for a color token. +public struct ColorTokenMetadata: Sendable { + public let variableId: String? + public let fileId: String? + + public init(variableId: String? = nil, fileId: String? = nil) { + self.variableId = variableId + self.fileId = fileId + } +} + +// MARK: - V2025 Implementation + +extension W3CTokensExporter { + private func exportColorsV2025( + colorsByMode: [String: [Color]], + descriptions: [String: String], + metadata: [String: ColorTokenMetadata], + aliases: [String: [String: String]] = [:], + modeKeyToName: [String: String] = [:] ) -> [String: Any] { - // Group colors by name across all modes - var colorValues: [String: [String: String]] = [:] // name -> mode -> hex + let nameToKey = Dictionary(uniqueKeysWithValues: modeKeyToName.map { ($0.value, $0.key) }) + var colorsByName: [String: [(mode: String, color: Color)]] = [:] for (modeName, colors) in colorsByMode { for color in colors { - let hex = colorToHex(r: color.red, g: color.green, b: color.blue, a: color.alpha) + colorsByName[color.name, default: []].append((modeName, color)) + } + } + + var tokens: [String: Any] = [:] + + for (name, modeColors) in colorsByName { + let path = nameToHierarchy(name) + let colorAliases = aliases[name] ?? [:] + let sortedModeColors = sortByModeOrder(modeColors) + guard let defaultEntry = sortedModeColors.first else { continue } + let defaultModeKey = nameToKey[defaultEntry.mode] ?? "light" + + var tokenValue: [String: Any] = ["$type": "color"] + tokenValue["$value"] = colorValueOrAlias( + color: defaultEntry.color, aliasPath: colorAliases[defaultModeKey] + ) + + if let description = descriptions[name], !description.trimmingCharacters(in: .whitespaces).isEmpty { + tokenValue["$description"] = description + } + + let exfigExtension = buildExfigExtension( + modeColors: modeColors, colorAliases: colorAliases, + nameToKey: nameToKey, metadata: metadata[name] + ) + if !exfigExtension.isEmpty { + tokenValue["$extensions"] = ["com.exfig": exfigExtension] + } + + insertToken(into: &tokens, path: path, value: tokenValue) + } + + return tokens + } + + private func sortByModeOrder(_ modeColors: [(mode: String, color: Color)]) -> [(mode: String, color: Color)] { + let modeOrder = ["Light", "Dark", "Contrast Light", "Contrast Dark"] + return modeColors.sorted { a, b in + let ai = modeOrder.firstIndex(of: a.mode) ?? modeOrder.count + let bi = modeOrder.firstIndex(of: b.mode) ?? modeOrder.count + return ai < bi + } + } + + private func colorValueOrAlias(color: Color, aliasPath: String?) -> Any { + if let aliasPath { + return "{\(Self.toW3CPath(aliasPath))}" + } + return colorToObject(color) + } + + private func buildExfigExtension( + modeColors: [(mode: String, color: Color)], + colorAliases: [String: String], + nameToKey: [String: String], + metadata: ColorTokenMetadata? + ) -> [String: Any] { + var ext: [String: Any] = [:] + + if modeColors.count > 1 { + var modes: [String: Any] = [:] + for (modeName, color) in modeColors { + let modeKey = nameToKey[modeName] ?? "light" + modes[modeName] = colorValueOrAlias(color: color, aliasPath: colorAliases[modeKey]) + } + ext["modes"] = modes + } + + if let meta = metadata { + if let variableId = meta.variableId { ext["variableId"] = variableId } + if let fileId = meta.fileId { ext["fileId"] = fileId } + } + + return ext + } + + /// Converts a Figma variable path (slash-separated) to W3C alias path (dot-separated). + static func toW3CPath(_ figmaPath: String) -> String { + figmaPath.replacingOccurrences(of: "/", with: ".") + } + + private func exportTypographyV2025(textStyles: [TextStyle]) -> [String: Any] { + var tokens: [String: Any] = [:] + + for style in textStyles { + let path = nameToHierarchy(style.name) + var tokenValue: [String: Any] = ["$type": "typography"] + + // Composite $value uses v2025 formats + var value: [String: Any] = [ + "fontFamily": [style.fontName], + "fontSize": ["value": style.fontSize, "unit": "px"], + ] + + if let lineHeight = style.lineHeight { + // Convert px to ratio when fontSize is available + let ratio = lineHeight / style.fontSize + value["lineHeight"] = ratio + } + + if style.letterSpacing != 0 { + value["letterSpacing"] = ["value": style.letterSpacing, "unit": "px"] + } + + switch style.textCase { + case .uppercased: + value["textTransform"] = "uppercase" + case .lowercased: + value["textTransform"] = "lowercase" + case .original: + break + } + + tokenValue["$value"] = value + insertToken(into: &tokens, path: path, value: tokenValue) + + // Sub-tokens + + // fontFamily + let fontFamilyToken: [String: Any] = [ + "$type": "fontFamily", + "$value": [style.fontName], + ] + insertToken(into: &tokens, path: path + ["fontFamily"], value: fontFamilyToken) + + // fontSize + let fontSizeToken: [String: Any] = [ + "$type": "dimension", + "$value": ["value": style.fontSize, "unit": "px"], + ] + insertToken(into: &tokens, path: path + ["fontSize"], value: fontSizeToken) + + // lineHeight (only if set) + if let lineHeight = style.lineHeight { + let ratio = lineHeight / style.fontSize + let lineHeightToken: [String: Any] = [ + "$type": "number", + "$value": ratio, + ] + insertToken(into: &tokens, path: path + ["lineHeight"], value: lineHeightToken) + } + + // letterSpacing (only if non-zero) + if style.letterSpacing != 0 { + let letterSpacingToken: [String: Any] = [ + "$type": "dimension", + "$value": ["value": style.letterSpacing, "unit": "px"], + ] + insertToken(into: &tokens, path: path + ["letterSpacing"], value: letterSpacingToken) + } + } + + return tokens + } + + private func exportAssetsV2025(assets: [AssetToken]) -> [String: Any] { + var tokens: [String: Any] = [:] + + for asset in assets { + let path = nameToHierarchy(asset.name) + var tokenValue: [String: Any] = [:] + + if let description = asset.description, !description.isEmpty { + tokenValue["$description"] = description + } + + // No $type — asset is not a W3C type. Use $extensions. + var exfigExtension: [String: Any] = ["assetUrl": asset.url] + if let nodeId = asset.nodeId { + exfigExtension["nodeId"] = nodeId + } + if let fileId = asset.fileId { + exfigExtension["fileId"] = fileId + } + + tokenValue["$extensions"] = ["com.exfig": exfigExtension] + + insertToken(into: &tokens, path: path, value: tokenValue) + } + + return tokens + } +} + +// MARK: - V1 Implementation (Legacy) + +extension W3CTokensExporter { + private func exportColorsV1( + colorsByMode: [String: [Color]], + descriptions: [String: String] + ) -> [String: Any] { + var colorValues: [String: [String: String]] = [:] + + for (modeName, colors) in colorsByMode { + for color in colors { + let hex = colorToHexLegacy(r: color.red, g: color.green, b: color.blue, a: color.alpha) colorValues[color.name, default: [:]][modeName] = hex } } - // Build nested token structure var tokens: [String: Any] = [:] for (name, modeValues) in colorValues { let path = nameToHierarchy(name) var tokenValue: [String: Any] = [ "$type": "color", + "$value": modeValues, ] - // Always use dict format for consistency (mode name -> hex value) - tokenValue["$value"] = modeValues - if let description = descriptions[name], !description.isEmpty { tokenValue["$description"] = description } @@ -88,20 +461,12 @@ public struct W3CTokensExporter: Sendable { return tokens } - // MARK: - Typography Export - - /// Exports text styles to W3C Design Tokens format. - /// - /// - Parameter textStyles: Array of text styles to export - /// - Returns: Nested dictionary structure representing W3C tokens - public func exportTypography(textStyles: [TextStyle]) -> [String: Any] { + private func exportTypographyV1(textStyles: [TextStyle]) -> [String: Any] { var tokens: [String: Any] = [:] for style in textStyles { let path = nameToHierarchy(style.name) - var tokenValue: [String: Any] = [ - "$type": "typography", - ] + var tokenValue: [String: Any] = ["$type": "typography"] var value: [String: Any] = [ "fontFamily": style.fontName, @@ -132,13 +497,7 @@ public struct W3CTokensExporter: Sendable { return tokens } - // MARK: - Assets Export - - /// Exports assets (icons, images) to W3C Design Tokens format. - /// - /// - Parameter assets: Array of asset tokens to export - /// - Returns: Nested dictionary structure representing W3C tokens - public func exportAssets(assets: [AssetToken]) -> [String: Any] { + private func exportAssetsV1(assets: [AssetToken]) -> [String: Any] { var tokens: [String: Any] = [:] for asset in assets { @@ -157,25 +516,57 @@ public struct W3CTokensExporter: Sendable { return tokens } +} - // MARK: - JSON Serialization +// MARK: - Export from TokensFileSource - /// Serializes tokens to JSON data. - /// - /// - Parameters: - /// - tokens: Token dictionary to serialize - /// - compact: If true, outputs minified JSON; otherwise pretty-printed - /// - Returns: JSON data - public func serializeToJSON(_ tokens: [String: Any], compact: Bool) throws -> Data { - let options: JSONSerialization.WritingOptions = compact - ? [.sortedKeys] - : [.prettyPrinted, .sortedKeys] +extension W3CTokensExporter { + /// Exports all token types from a resolved `TokensFileSource` into a merged W3C dictionary. + func exportAll(from source: TokensFileSource) -> [String: Any] { + var allTokens: [String: Any] = [:] - return try JSONSerialization.data(withJSONObject: tokens, options: options) + let colors = source.toColors() + if !colors.isEmpty { + Self.mergeTokens(from: exportColors(colorsByMode: ["Default": colors]), into: &allTokens) + } + let textStyles = source.toTextStyles() + if !textStyles.isEmpty { + Self.mergeTokens(from: exportTypography(textStyles: textStyles), into: &allTokens) + } + let dimensions = source.toDimensionTokens() + if !dimensions.isEmpty { + Self.mergeTokens(from: exportDimensions(tokens: dimensions), into: &allTokens) + } + let numbers = source.toNumberTokens() + if !numbers.isEmpty { + Self.mergeTokens(from: exportNumbers(tokens: numbers), into: &allTokens) + } + return allTokens } +} - // MARK: - Private Helpers +// MARK: - Token Merging + +extension W3CTokensExporter { + /// Deep-merges source dictionary into target, preserving existing keys. + static func mergeTokens(from source: [String: Any], into target: inout [String: Any]) { + for (key, value) in source { + if let sourceDict = value as? [String: Any], + let targetDict = target[key] as? [String: Any] + { + var merged = targetDict + mergeTokens(from: sourceDict, into: &merged) + target[key] = merged + } else { + target[key] = value + } + } + } +} + +// MARK: - Private Helpers +extension W3CTokensExporter { private func insertToken(into dict: inout [String: Any], path: [String], value: [String: Any]) { guard !path.isEmpty else { return } @@ -189,3 +580,5 @@ public struct W3CTokensExporter: Sendable { } } } + +// swiftlint:enable file_length diff --git a/Sources/ExFigCLI/Resources/Schemas/Common.pkl b/Sources/ExFigCLI/Resources/Schemas/Common.pkl index 96711a58..9dc36c13 100644 --- a/Sources/ExFigCLI/Resources/Schemas/Common.pkl +++ b/Sources/ExFigCLI/Resources/Schemas/Common.pkl @@ -30,6 +30,18 @@ class WebpOptions { quality: Int(isBetween(0, 100))? } +// MARK: - Tokens File Source + +/// Local W3C DTCG .tokens.json file source. +/// When set on a colors entry, bypasses Figma API and reads tokens from a local file. +class TokensFile { + /// Path to the .tokens.json file. + path: String(!isEmpty) + + /// Optional dot-path prefix to filter tokens (e.g., "Brand.Colors"). + groupFilter: String? +} + // MARK: - Cache /// Cache configuration for tracking Figma file versions. @@ -58,6 +70,9 @@ open class NameProcessing { /// Used for colors that come from Figma Variables API. /// All fields are optional to support legacy format where source comes from common.variablesColors. open class VariablesSource extends NameProcessing { + /// Local .tokens.json file source (bypasses Figma API when set). + tokensFile: TokensFile? + /// Figma file ID containing the variables. tokensFileId: String? diff --git a/Sources/ExFigCLI/Subcommands/Download.swift b/Sources/ExFigCLI/Subcommands/Download.swift index 3ec08cdd..dc7dc050 100644 --- a/Sources/ExFigCLI/Subcommands/Download.swift +++ b/Sources/ExFigCLI/Subcommands/Download.swift @@ -24,6 +24,9 @@ public struct JSONExportOptions: ParsableArguments { @Option(name: .shortAndLong, help: "Output format: w3c (default) or raw") public var format: JSONExportFormat = .w3c + @Option(name: .long, help: "W3C spec version: v2025 (default) or v1 (legacy hex format)") + public var w3cVersion: W3CVersion = .v2025 + @Flag(name: .long, help: "Output minified JSON") public var compact: Bool = false @@ -57,6 +60,7 @@ extension ExFigCommand { DownloadTypography.self, DownloadIcons.self, DownloadImages.self, + DownloadTokens.self, DownloadAll.self, ] ) @@ -173,7 +177,9 @@ extension ExFigCommand.Download { filter: filterValue ) let output = try await loader.load() - return ColorsVariablesLoader.LoadResult(output: output, warnings: []) + return ColorsVariablesLoader.LoadResult( + output: output, warnings: [], aliases: [:], descriptions: [:], metadata: [:] + ) } } @@ -183,8 +189,12 @@ extension ExFigCommand.Download { try ColorExportHelper.exportW3C( colors: colorsResult.output, + descriptions: colorsResult.descriptions, + metadata: colorsResult.metadata, + aliases: colorsResult.aliases, outputURL: outputURL, - compact: jsonOptions.compact + compact: jsonOptions.compact, + w3cVersion: jsonOptions.w3cVersion ) } diff --git a/Sources/ExFigCLI/Subcommands/DownloadAll.swift b/Sources/ExFigCLI/Subcommands/DownloadAll.swift index 71187164..e12eaf36 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadAll.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadAll.swift @@ -86,7 +86,9 @@ extension ExFigCommand.Download { filter: nil ) let output = try await loader.load() - return ColorsVariablesLoader.LoadResult(output: output, warnings: []) + return ColorsVariablesLoader.LoadResult( + output: output, warnings: [], aliases: [:], descriptions: [:], metadata: [:] + ) } } @@ -101,8 +103,12 @@ extension ExFigCommand.Download { case .w3c: try ColorExportHelper.exportW3C( colors: colors, + descriptions: colorsResult.descriptions, + metadata: colorsResult.metadata, + aliases: colorsResult.aliases, outputURL: outputURL, - compact: jsonOptions.compact + compact: jsonOptions.compact, + w3cVersion: jsonOptions.w3cVersion ) case .raw: @@ -138,7 +144,8 @@ extension ExFigCommand.Download { try TypographyExportHelper.exportW3C( textStyles: textStyles, outputURL: outputURL, - compact: jsonOptions.compact + compact: jsonOptions.compact, + w3cVersion: jsonOptions.w3cVersion ) case .raw: @@ -209,8 +216,10 @@ extension ExFigCommand.Download { try AssetExportHelper.exportW3C( components: components, exportUrls: exportUrls, + fileId: fileId, outputURL: outputURL, - compact: jsonOptions.compact + compact: jsonOptions.compact, + w3cVersion: jsonOptions.w3cVersion ) case .raw: @@ -277,8 +286,10 @@ extension ExFigCommand.Download { try AssetExportHelper.exportW3C( components: components, exportUrls: exportUrls, + fileId: fileId, outputURL: outputURL, - compact: jsonOptions.compact + compact: jsonOptions.compact, + w3cVersion: jsonOptions.w3cVersion ) case .raw: diff --git a/Sources/ExFigCLI/Subcommands/DownloadIcons.swift b/Sources/ExFigCLI/Subcommands/DownloadIcons.swift index 3e433049..47c479ba 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadIcons.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadIcons.swift @@ -122,8 +122,10 @@ extension ExFigCommand.Download { try AssetExportHelper.exportW3C( components: components, exportUrls: exportUrls, + fileId: fileId, outputURL: outputURL, - compact: jsonOptions.compact + compact: jsonOptions.compact, + w3cVersion: jsonOptions.w3cVersion ) case .raw: diff --git a/Sources/ExFigCLI/Subcommands/DownloadImagesExport.swift b/Sources/ExFigCLI/Subcommands/DownloadImagesExport.swift index b4de3a60..2b48f665 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadImagesExport.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadImagesExport.swift @@ -95,8 +95,10 @@ extension ExFigCommand.Download { try AssetExportHelper.exportW3C( components: components, exportUrls: exportUrls, + fileId: fileId, outputURL: outputURL, - compact: jsonOptions.compact + compact: jsonOptions.compact, + w3cVersion: jsonOptions.w3cVersion ) case .raw: diff --git a/Sources/ExFigCLI/Subcommands/DownloadTokens.swift b/Sources/ExFigCLI/Subcommands/DownloadTokens.swift new file mode 100644 index 00000000..2d39bb02 --- /dev/null +++ b/Sources/ExFigCLI/Subcommands/DownloadTokens.swift @@ -0,0 +1,156 @@ +import ArgumentParser +import ExFigCore +import FigmaAPI +import Foundation + +// MARK: - Download Tokens + +extension ExFigCommand.Download { + /// Downloads all design tokens (colors, typography, dimensions, numbers) as a unified W3C JSON file. + struct DownloadTokens: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "tokens", + abstract: "Downloads unified design tokens from Figma as W3C JSON" + ) + + @OptionGroup + var globalOptions: GlobalOptions + + @OptionGroup + var options: ExFigOptions + + @OptionGroup + var jsonOptions: JSONExportOptions + + @OptionGroup + var faultToleranceOptions: FaultToleranceOptions + + func run() async throws { + ExFigCommand.initializeTerminalUI(verbose: globalOptions.verbose, quiet: globalOptions.quiet) + let ui = ExFigCommand.terminalUI! + + let baseClient = FigmaClient(accessToken: options.accessToken, timeout: options.params.figma?.timeout) + let rateLimiter = faultToleranceOptions.createRateLimiter() + let client = faultToleranceOptions.createRateLimitedClient( + wrapping: baseClient, + rateLimiter: rateLimiter, + onRetry: { attempt, error in + ui.warning("Retry \(attempt) after error: \(error.localizedDescription)") + } + ) + + ui.info("Downloading unified design tokens from Figma...") + + let outputPath = jsonOptions.output ?? "tokens.json" + let outputURL = URL(fileURLWithPath: outputPath) + let exporter = W3CTokensExporter(version: jsonOptions.w3cVersion) + + var allTokens: [String: Any] = [:] + + if options.params.common?.variablesColors != nil { + try await exportColors(client: client, exporter: exporter, into: &allTokens, ui: ui) + } else { + ui.warning(.downloadTokensSectionSkipped(section: "colors")) + } + + if options.params.figma != nil { + try await exportTypography(client: client, exporter: exporter, into: &allTokens, ui: ui) + } else { + ui.warning(.downloadTokensSectionSkipped(section: "typography")) + } + + if options.params.common?.variablesColors != nil { + try await exportNumbers(client: client, exporter: exporter, into: &allTokens, ui: ui) + } else { + ui.warning(.downloadTokensSectionSkipped(section: "numbers")) + } + + if allTokens.isEmpty { + throw ExFigError.custom(errorString: "No token sections configured for export. Check your config file.") + } + + let jsonData = try exporter.serializeToJSON(allTokens, compact: jsonOptions.compact) + try jsonData.write(to: outputURL) + + ui.success("Exported unified tokens to \(outputPath)") + } + + private func exportColors( + client: Client, exporter: W3CTokensExporter, + into allTokens: inout [String: Any], ui: TerminalUI + ) async throws { + guard let variableParams = options.params.common?.variablesColors else { + return + } + + let colorsResult = try await ui.withSpinner("Fetching colors...") { + let loader = ColorsVariablesLoader(client: client, variableParams: variableParams, filter: nil) + return try await loader.load() + } + + for warning in colorsResult.warnings { + ui.warning(warning) + } + + let colorsByMode = ColorExportHelper.buildColorsByMode(from: colorsResult.output) + let colorTokens = exporter.exportColors( + colorsByMode: colorsByMode, + descriptions: colorsResult.descriptions, + metadata: colorsResult.metadata, + aliases: colorsResult.aliases, + modeKeyToName: ColorExportHelper.modeKeyToName + ) + Self.mergeTokens(from: colorTokens, into: &allTokens) + } + + private func exportTypography( + client: Client, exporter: W3CTokensExporter, + into allTokens: inout [String: Any], ui: TerminalUI + ) async throws { + guard let figmaParams = options.params.figma else { + return + } + + let textStyles = try await ui.withSpinner("Fetching text styles...") { + let loader = TextStylesLoader(client: client, params: figmaParams) + return try await loader.load() + } + + Self.mergeTokens(from: exporter.exportTypography(textStyles: textStyles), into: &allTokens) + } + + private func exportNumbers( + client: Client, exporter: W3CTokensExporter, + into allTokens: inout [String: Any], ui: TerminalUI + ) async throws { + guard let variableParams = options.params.common?.variablesColors else { + return + } + + let result = try await ui.withSpinner("Fetching number variables...") { + let loader = NumberVariablesLoader( + client: client, + tokensFileId: variableParams.tokensFileId, + tokensCollectionName: variableParams.tokensCollectionName + ) + return try await loader.load() + } + + for warning in result.warnings { + ui.warning(warning) + } + + if !result.dimensions.isEmpty { + Self.mergeTokens(from: exporter.exportDimensions(tokens: result.dimensions), into: &allTokens) + } + if !result.numbers.isEmpty { + Self.mergeTokens(from: exporter.exportNumbers(tokens: result.numbers), into: &allTokens) + } + } + + /// Deep-merges source dictionary into target, preserving existing keys. + static func mergeTokens(from source: [String: Any], into target: inout [String: Any]) { + W3CTokensExporter.mergeTokens(from: source, into: &target) + } + } +} diff --git a/Sources/ExFigCLI/Subcommands/DownloadTypography.swift b/Sources/ExFigCLI/Subcommands/DownloadTypography.swift index 1c0b5e99..2dba1c13 100644 --- a/Sources/ExFigCLI/Subcommands/DownloadTypography.swift +++ b/Sources/ExFigCLI/Subcommands/DownloadTypography.swift @@ -58,7 +58,8 @@ extension ExFigCommand.Download { try TypographyExportHelper.exportW3C( textStyles: textStyles, outputURL: outputURL, - compact: jsonOptions.compact + compact: jsonOptions.compact, + w3cVersion: jsonOptions.w3cVersion ) case .raw: diff --git a/Sources/ExFigCLI/Subcommands/Tokens.swift b/Sources/ExFigCLI/Subcommands/Tokens.swift new file mode 100644 index 00000000..ef5c2384 --- /dev/null +++ b/Sources/ExFigCLI/Subcommands/Tokens.swift @@ -0,0 +1,12 @@ +import ArgumentParser + +extension ExFigCommand { + struct Tokens: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "tokens", + abstract: "Work with local .tokens.json files (no config or Figma token needed)", + subcommands: [TokensInfo.self, TokensConvert.self], + defaultSubcommand: TokensInfo.self + ) + } +} diff --git a/Sources/ExFigCLI/Subcommands/TokensConvert.swift b/Sources/ExFigCLI/Subcommands/TokensConvert.swift new file mode 100644 index 00000000..4033b783 --- /dev/null +++ b/Sources/ExFigCLI/Subcommands/TokensConvert.swift @@ -0,0 +1,74 @@ +import ArgumentParser +import ExFigCore +import Foundation + +extension ExFigCommand.Tokens { + /// Filters and re-exports a .tokens.json file in W3C Design Tokens format. + struct TokensConvert: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "convert", + abstract: "Filter and re-export a .tokens.json file as W3C JSON" + ) + + @Argument(help: "Path to the .tokens.json file") + var file: String + + @OptionGroup + var globalOptions: GlobalOptions + + @Option(name: .shortAndLong, help: "Output file path (default: stdout)") + var output: String? + + @Option(name: .long, help: "Filter by group path prefix (e.g., \"Brand.Colors\")") + var group: String? + + @Option( + name: .long, + parsing: .upToNextOption, + help: "Filter by token type(s): color, dimension, number, typography" + ) + var type: [String] = [] + + @Option(name: .long, help: "W3C spec version: v2025 (default) or v1 (legacy hex format)") + var w3cVersion: W3CVersion = .v2025 + + @Flag(name: .long, help: "Output minified JSON") + var compact: Bool = false + + func run() async throws { + ExFigCommand.initializeTerminalUI(verbose: globalOptions.verbose, quiet: globalOptions.quiet) + let ui = ExFigCommand.terminalUI! + + // Parse and resolve + var source = try TokensFileSource.parse(fileAt: file) + try source.resolveAliases() + + // Apply filters + if let group { + source = source.filteredByGroup(group) + } + if !type.isEmpty { + source = source.filteredByTypes(Set(type)) + } + + if source.tokens.isEmpty { + ui.warning("No tokens match the given filters") + return + } + + // Export + let exporter = W3CTokensExporter(version: w3cVersion) + let allTokens = exporter.exportAll(from: source) + let jsonData = try exporter.serializeToJSON(allTokens, compact: compact) + + if let outputPath = output { + let outputURL = URL(fileURLWithPath: outputPath) + try jsonData.write(to: outputURL) + ui.success("Exported \(source.tokens.count) tokens to \(outputPath)") + } else { + FileHandle.standardOutput.write(jsonData) + FileHandle.standardOutput.write(Data("\n".utf8)) + } + } + } +} diff --git a/Sources/ExFigCLI/Subcommands/TokensInfo.swift b/Sources/ExFigCLI/Subcommands/TokensInfo.swift new file mode 100644 index 00000000..ed6c5347 --- /dev/null +++ b/Sources/ExFigCLI/Subcommands/TokensInfo.swift @@ -0,0 +1,108 @@ +import ArgumentParser +import ExFigCore +import Foundation + +extension ExFigCommand.Tokens { + /// Inspects a local .tokens.json file and prints a summary. + struct TokensInfo: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "info", + abstract: "Inspect a .tokens.json file (types, groups, warnings)" + ) + + @Argument(help: "Path to the .tokens.json file") + var file: String + + @OptionGroup + var globalOptions: GlobalOptions + + @Flag(name: .long, help: "Output machine-readable JSON") + var json: Bool = false + + func run() async throws { + ExFigCommand.initializeTerminalUI(verbose: globalOptions.verbose, quiet: globalOptions.quiet) + let ui = ExFigCommand.terminalUI! + + var source = try TokensFileSource.parse(fileAt: file) + try source.resolveAliases() + + if json { + try printJSON(source: source) + } else { + printHuman(source: source, ui: ui) + } + } + + // MARK: - Human-Readable Output + + private func printHuman(source: TokensFileSource, ui: TerminalUI) { + let totalCount = source.tokens.count + let countsByType = source.tokenCountsByType() + let groups = source.topLevelGroups() + + ui.info("Token file: \(file)") + ui.info("Tokens: \(totalCount) total") + + if !countsByType.isEmpty { + let maxTypeLen = countsByType.map(\.type.count).max() ?? 0 + for entry in countsByType { + let pct = totalCount > 0 + ? String(format: "%.1f%%", Double(entry.count) / Double(totalCount) * 100) + : "0%" + let padded = entry.type.padding(toLength: maxTypeLen + 1, withPad: " ", startingAt: 0) + ui.info(" \(padded) \(entry.count) (\(pct))") + } + } + + if !groups.isEmpty { + ui.info("") + ui.info("Groups:") + for group in groups { + let suffix = group.count == 1 ? "token" : "tokens" + ui.info(" \(group.name) (\(group.count) \(suffix))") + } + } + + if source.aliasCount > 0 { + ui.info("") + ui.info("Aliases: \(source.aliasCount) resolved") + } + + if !source.warnings.isEmpty { + ui.info("Warnings: \(source.warnings.count)") + for warning in source.warnings { + ui.warning(warning) + } + } + } + + // MARK: - JSON Output + + private func printJSON(source: TokensFileSource) throws { + let report = TokensInfoReport( + file: file, + totalTokens: source.tokens.count, + aliases: source.aliasCount, + types: Dictionary( + uniqueKeysWithValues: source.tokenCountsByType().map { ($0.type, $0.count) } + ), + groups: Dictionary( + uniqueKeysWithValues: source.topLevelGroups().map { ($0.name, $0.count) } + ), + warnings: source.warnings + ) + let jsonData = try JSONCodec.encodePrettySorted(report) + FileHandle.standardOutput.write(jsonData) + FileHandle.standardOutput.write(Data("\n".utf8)) + } + } +} + +private struct TokensInfoReport: Codable { + let file: String + let totalTokens: Int + let aliases: Int + let types: [String: Int] + let groups: [String: Int] + let warnings: [String] +} diff --git a/Sources/ExFigCLI/TerminalUI/ExFigWarning.swift b/Sources/ExFigCLI/TerminalUI/ExFigWarning.swift index 70e4cd9d..274bcb5f 100644 --- a/Sources/ExFigCLI/TerminalUI/ExFigWarning.swift +++ b/Sources/ExFigCLI/TerminalUI/ExFigWarning.swift @@ -93,4 +93,16 @@ enum ExFigWarning: Sendable, Equatable { /// A color token references a deleted-but-referenced variable via alias. case deletedVariableAlias(tokenName: String, referencedName: String) + + /// A number variable alias could not be resolved. + case unresolvedNumberAlias(tokenName: String) + + /// A number variable alias chain exceeded maximum depth (likely circular). + case depthExceededNumberAlias(tokenName: String) + + /// A color variable alias chain exceeded maximum depth (likely circular). + case circularColorAlias(tokenName: String) + + /// A `download tokens` section was skipped because config is missing. + case downloadTokensSectionSkipped(section: String) } diff --git a/Sources/ExFigCLI/TerminalUI/ExFigWarningFormatter.swift b/Sources/ExFigCLI/TerminalUI/ExFigWarningFormatter.swift index fd590e4f..10c190f5 100644 --- a/Sources/ExFigCLI/TerminalUI/ExFigWarningFormatter.swift +++ b/Sources/ExFigCLI/TerminalUI/ExFigWarningFormatter.swift @@ -18,7 +18,9 @@ struct ExFigWarningFormatter { .preFetchComponentsPartialFailure, .preFetchNodesPartialFailure, .granularCacheWithoutCache, .themeAttributesFileNotFound, .themeAttributesMarkerNotFound, .themeAttributesNameCollision, - .heicUnavailableFallingBackToPng, .deletedVariableAlias: + .heicUnavailableFallingBackToPng, .deletedVariableAlias, + .unresolvedNumberAlias, .depthExceededNumberAlias, + .circularColorAlias, .downloadTokensSectionSkipped: formatCompact(warning) // Multiline format warnings @@ -92,6 +94,18 @@ struct ExFigWarningFormatter { case let .deletedVariableAlias(tokenName, referencedName): "Skipped deleted variable alias: token=\(tokenName), referenced=\(referencedName)" + case let .unresolvedNumberAlias(tokenName): + "Could not resolve number variable alias: token=\(tokenName)" + + case let .depthExceededNumberAlias(tokenName): + "Number alias chain exceeded max depth (likely circular): token=\(tokenName)" + + case let .circularColorAlias(tokenName): + "Color alias chain exceeded max depth (likely circular): token=\(tokenName)" + + case let .downloadTokensSectionSkipped(section): + "Token section skipped (not configured): section=\(section)" + // Multiline cases handled in main format() method case .noAssetsFound, .invalidConfigsSkipped, .webIconsMissingSVGData, .webIconsConversionFailed: fatalError("Multiline warnings should not reach formatCompact") diff --git a/Sources/ExFigConfig/CLAUDE.md b/Sources/ExFigConfig/CLAUDE.md index ba1088c3..b09e6dd7 100644 --- a/Sources/ExFigConfig/CLAUDE.md +++ b/Sources/ExFigConfig/CLAUDE.md @@ -42,16 +42,18 @@ ExFigCore domain types (NameStyle, ColorsSourceInput, etc.) - `Common_NameProcessing` — base protocol (`nameValidateRegexp`, `nameReplaceRegexp`) - `Common_VariablesSource` extends `NameProcessing` — colors from Figma Variables API - `Common_FrameSource` extends `NameProcessing` — icons/images from Figma frames +- `Common.TokensFile` — local `.tokens.json` file source (`path` + optional `groupFilter`) +- `Common_VariablesSource` now includes optional `tokensFile: Common.TokensFile?` field - Platform entry types (`iOS.ColorsEntry`, `Android.IconsEntry`, etc.) implement these protocols ### Key Public API -| Symbol | Purpose | -| ------------------------------------------------------- | ------------------------------------------------------ | -| `PKLEvaluator.evaluate(configPath:)` | Async evaluation of .pkl → `ExFig.ModuleImpl` | -| `PKLError.configNotFound` / `.evaluationDidNotComplete` | Error cases | -| `Common.NameStyle.coreNameStyle` | Bridge to `ExFigCore.NameStyle` via rawValue match | -| `Common_VariablesSource.validatedColorsSourceInput()` | Validates required fields, returns `ColorsSourceInput` | +| Symbol | Purpose | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `PKLEvaluator.evaluate(configPath:)` | Async evaluation of .pkl → `ExFig.ModuleImpl` | +| `PKLError.configNotFound` / `.evaluationDidNotComplete` | Error cases | +| `Common.NameStyle.coreNameStyle` | Bridge to `ExFigCore.NameStyle` via rawValue match | +| `Common_VariablesSource.validatedColorsSourceInput()` | Validates required fields, returns `ColorsSourceInput`. When `tokensFile` is set, bypasses Figma field validation and returns local source input | ### PklError Workaround diff --git a/Sources/ExFigConfig/Generated/Android.pkl.swift b/Sources/ExFigConfig/Generated/Android.pkl.swift index 8c71f2c8..e2852361 100644 --- a/Sources/ExFigConfig/Generated/Android.pkl.swift +++ b/Sources/ExFigConfig/Generated/Android.pkl.swift @@ -99,6 +99,9 @@ extension Android { /// Theme attributes configuration. public var themeAttributes: ThemeAttributes? + /// Local .tokens.json file source (bypasses Figma API when set). + public var tokensFile: Common.TokensFile? + /// Figma file ID containing the variables. public var tokensFileId: String? @@ -135,6 +138,7 @@ extension Android { composePackageName: String?, colorKotlin: String?, themeAttributes: ThemeAttributes?, + tokensFile: Common.TokensFile?, tokensFileId: String?, tokensCollectionName: String?, lightModeName: String?, @@ -153,6 +157,7 @@ extension Android { self.composePackageName = composePackageName self.colorKotlin = colorKotlin self.themeAttributes = themeAttributes + self.tokensFile = tokensFile self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName self.lightModeName = lightModeName diff --git a/Sources/ExFigConfig/Generated/Common.pkl.swift b/Sources/ExFigConfig/Generated/Common.pkl.swift index b9a8fa34..314651f0 100644 --- a/Sources/ExFigConfig/Generated/Common.pkl.swift +++ b/Sources/ExFigConfig/Generated/Common.pkl.swift @@ -4,6 +4,8 @@ import PklSwift public enum Common {} public protocol Common_VariablesSource: Common_NameProcessing { + var tokensFile: Common.TokensFile? { get } + var tokensFileId: String? { get } var tokensCollectionName: String? { get } @@ -74,6 +76,9 @@ extension Common { public struct VariablesSourceImpl: VariablesSource { public static let registeredIdentifier: String = "Common#VariablesSource" + /// Local .tokens.json file source (bypasses Figma API when set). + public var tokensFile: TokensFile? + /// Figma file ID containing the variables. public var tokensFileId: String? @@ -102,6 +107,7 @@ extension Common { public var nameReplaceRegexp: String? public init( + tokensFile: TokensFile?, tokensFileId: String?, tokensCollectionName: String?, lightModeName: String?, @@ -112,6 +118,7 @@ extension Common { nameValidateRegexp: String?, nameReplaceRegexp: String? ) { + self.tokensFile = tokensFile self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName self.lightModeName = lightModeName @@ -124,21 +131,20 @@ extension Common { } } - public typealias NameProcessing = Common_NameProcessing + /// Local W3C DTCG .tokens.json file source. + /// When set on a colors entry, bypasses Figma API and reads tokens from a local file. + public struct TokensFile: PklRegisteredType, Decodable, Hashable, Sendable { + public static let registeredIdentifier: String = "Common#TokensFile" - /// Name validation and transformation configuration. - public struct NameProcessingImpl: NameProcessing { - public static let registeredIdentifier: String = "Common#NameProcessing" + /// Path to the .tokens.json file. + public var path: String - /// Regex pattern for validating/capturing names. - public var nameValidateRegexp: String? + /// Optional dot-path prefix to filter tokens (e.g., "Brand.Colors"). + public var groupFilter: String? - /// Replacement pattern using captured groups. - public var nameReplaceRegexp: String? - - public init(nameValidateRegexp: String?, nameReplaceRegexp: String?) { - self.nameValidateRegexp = nameValidateRegexp - self.nameReplaceRegexp = nameReplaceRegexp + public init(path: String, groupFilter: String?) { + self.path = path + self.groupFilter = groupFilter } } @@ -181,6 +187,24 @@ extension Common { } } + public typealias NameProcessing = Common_NameProcessing + + /// Name validation and transformation configuration. + public struct NameProcessingImpl: NameProcessing { + public static let registeredIdentifier: String = "Common#NameProcessing" + + /// Regex pattern for validating/capturing names. + public var nameValidateRegexp: String? + + /// Replacement pattern using captured groups. + public var nameReplaceRegexp: String? + + public init(nameValidateRegexp: String?, nameReplaceRegexp: String?) { + self.nameValidateRegexp = nameValidateRegexp + self.nameReplaceRegexp = nameReplaceRegexp + } + } + public typealias FrameSource = Common_FrameSource /// Figma Frame source configuration. diff --git a/Sources/ExFigConfig/Generated/Flutter.pkl.swift b/Sources/ExFigConfig/Generated/Flutter.pkl.swift index 0738b2ab..5ef62c5c 100644 --- a/Sources/ExFigConfig/Generated/Flutter.pkl.swift +++ b/Sources/ExFigConfig/Generated/Flutter.pkl.swift @@ -32,6 +32,9 @@ extension Flutter { /// Class name for generated colors. public var className: String? + /// Local .tokens.json file source (bypasses Figma API when set). + public var tokensFile: Common.TokensFile? + /// Figma file ID containing the variables. public var tokensFileId: String? @@ -63,6 +66,7 @@ extension Flutter { templatesPath: String?, output: String?, className: String?, + tokensFile: Common.TokensFile?, tokensFileId: String?, tokensCollectionName: String?, lightModeName: String?, @@ -76,6 +80,7 @@ extension Flutter { self.templatesPath = templatesPath self.output = output self.className = className + self.tokensFile = tokensFile self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName self.lightModeName = lightModeName diff --git a/Sources/ExFigConfig/Generated/Web.pkl.swift b/Sources/ExFigConfig/Generated/Web.pkl.swift index 9be72ff5..b562589c 100644 --- a/Sources/ExFigConfig/Generated/Web.pkl.swift +++ b/Sources/ExFigConfig/Generated/Web.pkl.swift @@ -35,6 +35,9 @@ extension Web { /// JSON filename for color data. Default: colors.json public var jsonFileName: String? + /// Local .tokens.json file source (bypasses Figma API when set). + public var tokensFile: Common.TokensFile? + /// Figma file ID containing the variables. public var tokensFileId: String? @@ -69,6 +72,7 @@ extension Web { cssFileName: String?, tsFileName: String?, jsonFileName: String?, + tokensFile: Common.TokensFile?, tokensFileId: String?, tokensCollectionName: String?, lightModeName: String?, @@ -85,6 +89,7 @@ extension Web { self.cssFileName = cssFileName self.tsFileName = tsFileName self.jsonFileName = jsonFileName + self.tokensFile = tokensFile self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName self.lightModeName = lightModeName diff --git a/Sources/ExFigConfig/Generated/iOS.pkl.swift b/Sources/ExFigConfig/Generated/iOS.pkl.swift index 7333f969..c5a14869 100644 --- a/Sources/ExFigConfig/Generated/iOS.pkl.swift +++ b/Sources/ExFigConfig/Generated/iOS.pkl.swift @@ -86,6 +86,9 @@ extension iOS { /// Example: "Color.{name}" → "Color.backgroundAccent" public var codeSyntaxTemplate: String? + /// Local .tokens.json file source (bypasses Figma API when set). + public var tokensFile: Common.TokensFile? + /// Figma file ID containing the variables. public var tokensFileId: String? @@ -124,6 +127,7 @@ extension iOS { templatesPath: String?, syncCodeSyntax: Bool?, codeSyntaxTemplate: String?, + tokensFile: Common.TokensFile?, tokensFileId: String?, tokensCollectionName: String?, lightModeName: String?, @@ -144,6 +148,7 @@ extension iOS { self.templatesPath = templatesPath self.syncCodeSyntax = syncCodeSyntax self.codeSyntaxTemplate = codeSyntaxTemplate + self.tokensFile = tokensFile self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName self.lightModeName = lightModeName diff --git a/Sources/ExFigConfig/VariablesSourceValidation.swift b/Sources/ExFigConfig/VariablesSourceValidation.swift index 21bc38e6..d2e57941 100644 --- a/Sources/ExFigConfig/VariablesSourceValidation.swift +++ b/Sources/ExFigConfig/VariablesSourceValidation.swift @@ -3,9 +3,28 @@ import ExFigCore public extension Common_VariablesSource { /// Returns a validated `ColorsSourceInput` for use with `ColorsExportContext`. /// - /// Throws if required fields (`tokensFileId`, `tokensCollectionName`, `lightModeName`) - /// are nil or empty. + /// When `tokensFile` is set, bypasses Figma API validation and returns a local-file source. + /// Otherwise, throws if required Figma fields (`tokensFileId`, `tokensCollectionName`, + /// `lightModeName`) are nil or empty. func validatedColorsSourceInput() throws -> ColorsSourceInput { + // Local tokens file source — bypass Figma validation + if let tokensFile { + return ColorsSourceInput( + tokensFilePath: tokensFile.path, + tokensFileGroupFilter: tokensFile.groupFilter, + tokensFileId: tokensFileId ?? "", + tokensCollectionName: tokensCollectionName ?? "", + lightModeName: lightModeName ?? "", + darkModeName: darkModeName, + lightHCModeName: lightHCModeName, + darkHCModeName: darkHCModeName, + primitivesModeName: primitivesModeName, + nameValidateRegexp: nameValidateRegexp, + nameReplaceRegexp: nameReplaceRegexp + ) + } + + // Figma Variables source — require all fields guard let tokensFileId, !tokensFileId.isEmpty else { throw ColorsConfigError.missingTokensFileId } diff --git a/Sources/ExFigCore/CLAUDE.md b/Sources/ExFigCore/CLAUDE.md index 4542940a..2c33bb86 100644 --- a/Sources/ExFigCore/CLAUDE.md +++ b/Sources/ExFigCore/CLAUDE.md @@ -23,6 +23,8 @@ Exporter.export*(entries, platformConfig, context) **Context protocols** (`ColorsExportContext`, `IconsExportContext`, etc.) inject all I/O dependencies — Figma loading, downloading, format conversion — so exporters stay pure transform logic. +**Local tokens file support:** `ColorsSourceInput` has optional `tokensFilePath` and `tokensFileGroupFilter` fields. When `tokensFilePath` is set, colors are loaded from a local `.tokens.json` file (W3C Design Tokens v2 format) instead of the Figma API. + ### Domain Models | Type | Role | diff --git a/Sources/ExFigCore/JSON/JSONCodec.swift b/Sources/ExFigCore/JSON/JSONCodec.swift index ac6bc2eb..02fc9e6a 100644 --- a/Sources/ExFigCore/JSON/JSONCodec.swift +++ b/Sources/ExFigCore/JSON/JSONCodec.swift @@ -1,11 +1,30 @@ import Foundation import YYJSON +/// DOM value for untyped JSON access via subscripts. +public typealias JSONValue = YYJSONValue + +/// DOM object for key-value iteration. +public typealias JSONObject = YYJSONObject + +/// DOM array for indexed/sequential access. +public typealias JSONArray = YYJSONArray + /// Centralized JSON codec based on YYJSON. /// /// High-performance replacement for Foundation JSON on all platforms. /// Use instead of direct JSONEncoder/JSONDecoder calls. public enum JSONCodec { + // MARK: - DOM Parsing + + /// Parse JSON data into a DOM value for untyped access. + /// + /// Use when the JSON structure is too dynamic for Codable. + /// Access values via subscripts: `value["key"]?.string`, `.number`, `.array`. + public static func parseValue(from data: Data) throws -> JSONValue { + try JSONValue(data: data) + } + // MARK: - Convenience Methods /// Encode value to JSON data. diff --git a/Sources/ExFigCore/Protocol/ExportContext.swift b/Sources/ExFigCore/Protocol/ExportContext.swift index bcad47c2..47df5ec2 100644 --- a/Sources/ExFigCore/Protocol/ExportContext.swift +++ b/Sources/ExFigCore/Protocol/ExportContext.swift @@ -91,8 +91,14 @@ public protocol ColorsExportContext: ExportContext { ) throws -> ColorsProcessResult } -/// Input for loading colors from Figma Variables. +/// Input for loading colors — either from Figma Variables API or a local .tokens.json file. +/// +/// When `tokensFilePath` is set, the export pipeline reads colors from the local file +/// (bypassing Figma API). Otherwise, `tokensFileId` + `tokensCollectionName` + `lightModeName` +/// are used to fetch from Figma Variables. public struct ColorsSourceInput: Sendable { + public let tokensFilePath: String? + public let tokensFileGroupFilter: String? public let tokensFileId: String public let tokensCollectionName: String public let lightModeName: String @@ -103,7 +109,14 @@ public struct ColorsSourceInput: Sendable { public let nameValidateRegexp: String? public let nameReplaceRegexp: String? + /// Whether this source input uses a local tokens file. + public var isLocalTokensFile: Bool { + tokensFilePath != nil + } + public init( + tokensFilePath: String? = nil, + tokensFileGroupFilter: String? = nil, tokensFileId: String, tokensCollectionName: String, lightModeName: String, @@ -114,6 +127,8 @@ public struct ColorsSourceInput: Sendable { nameValidateRegexp: String? = nil, nameReplaceRegexp: String? = nil ) { + self.tokensFilePath = tokensFilePath + self.tokensFileGroupFilter = tokensFileGroupFilter self.tokensFileId = tokensFileId self.tokensCollectionName = tokensCollectionName self.lightModeName = lightModeName diff --git a/Sources/FigmaAPI/Model/Variables.swift b/Sources/FigmaAPI/Model/Variables.swift index 42867ee5..61bd2dec 100644 --- a/Sources/FigmaAPI/Model/Variables.swift +++ b/Sources/FigmaAPI/Model/Variables.swift @@ -63,6 +63,8 @@ public struct VariableValue: Codable, Sendable { public var id: String public var name: String public var variableCollectionId: String + public var resolvedType: String? + public var scopes: [String]? public var valuesByMode: [String: ValuesByMode] public var description: String public var deletedButReferenced: Bool? diff --git a/Tests/ExFigTests/Input/EnumBridgingTests.swift b/Tests/ExFigTests/Input/EnumBridgingTests.swift index 0e9e976e..871986d2 100644 --- a/Tests/ExFigTests/Input/EnumBridgingTests.swift +++ b/Tests/ExFigTests/Input/EnumBridgingTests.swift @@ -66,6 +66,7 @@ final class EnumBridgingTests: XCTestCase { templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, + tokensFile: nil, tokensFileId: nil, tokensCollectionName: nil, lightModeName: nil, @@ -430,7 +431,7 @@ final class EnumBridgingTests: XCTestCase { templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, - tokensFileId: nil, + tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", lightModeName: "Light", darkModeName: nil, @@ -457,7 +458,7 @@ final class EnumBridgingTests: XCTestCase { templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, - tokensFileId: "", + tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", lightModeName: "Light", darkModeName: nil, @@ -484,7 +485,7 @@ final class EnumBridgingTests: XCTestCase { templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, - tokensFileId: "file123", + tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "Collection", lightModeName: "Light", darkModeName: "Dark", @@ -501,6 +502,94 @@ final class EnumBridgingTests: XCTestCase { XCTAssertEqual(sourceInput.darkModeName, "Dark") } + // MARK: - TokensFile Source Validation + + func testTokensFileSourceBypassesFigmaValidation() throws { + let entry = iOS.ColorsEntry( + useColorAssets: false, + assetsFolder: nil, + nameStyle: .camelCase, + groupUsingNamespace: nil, + colorSwift: nil, + swiftuiColorSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + syncCodeSyntax: nil, + codeSyntaxTemplate: nil, + tokensFile: Common.TokensFile(path: "tokens.json", groupFilter: nil), + tokensFileId: nil, + tokensCollectionName: nil, + lightModeName: nil, + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + let sourceInput = try entry.validatedColorsSourceInput() + XCTAssertTrue(sourceInput.isLocalTokensFile) + XCTAssertEqual(sourceInput.tokensFilePath, "tokens.json") + XCTAssertNil(sourceInput.tokensFileGroupFilter) + } + + func testTokensFileSourceWithGroupFilter() throws { + let entry = iOS.ColorsEntry( + useColorAssets: false, + assetsFolder: nil, + nameStyle: .camelCase, + groupUsingNamespace: nil, + colorSwift: nil, + swiftuiColorSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + syncCodeSyntax: nil, + codeSyntaxTemplate: nil, + tokensFile: Common.TokensFile(path: "design-tokens.json", groupFilter: "Brand.Colors"), + tokensFileId: nil, + tokensCollectionName: nil, + lightModeName: nil, + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + let sourceInput = try entry.validatedColorsSourceInput() + XCTAssertTrue(sourceInput.isLocalTokensFile) + XCTAssertEqual(sourceInput.tokensFilePath, "design-tokens.json") + XCTAssertEqual(sourceInput.tokensFileGroupFilter, "Brand.Colors") + } + + func testWithoutTokensFileFallsBackToFigmaValidation() { + let entry = iOS.ColorsEntry( + useColorAssets: false, + assetsFolder: nil, + nameStyle: .camelCase, + groupUsingNamespace: nil, + colorSwift: nil, + swiftuiColorSwift: nil, + xcassetsPath: nil, + templatesPath: nil, + syncCodeSyntax: nil, + codeSyntaxTemplate: nil, + tokensFile: nil, + tokensFileId: nil, + tokensCollectionName: nil, + lightModeName: nil, + darkModeName: nil, + lightHCModeName: nil, + darkHCModeName: nil, + primitivesModeName: nil, + nameValidateRegexp: nil, + nameReplaceRegexp: nil + ) + XCTAssertThrowsError(try entry.validatedColorsSourceInput()) { error in + XCTAssert(error is ColorsConfigError) + } + } + // MARK: - Android ColorsSourceInput Validation func testAndroidColorsEntryThrowsOnMissingTokensFileId() { @@ -513,7 +602,7 @@ final class EnumBridgingTests: XCTestCase { composePackageName: nil, colorKotlin: nil, themeAttributes: nil, - tokensFileId: nil, + tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", lightModeName: "Light", darkModeName: nil, @@ -538,7 +627,7 @@ final class EnumBridgingTests: XCTestCase { composePackageName: nil, colorKotlin: nil, themeAttributes: nil, - tokensFileId: "", + tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", lightModeName: "Light", darkModeName: nil, @@ -563,7 +652,7 @@ final class EnumBridgingTests: XCTestCase { composePackageName: nil, colorKotlin: nil, themeAttributes: nil, - tokensFileId: "file456", + tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "Colors", lightModeName: "Light", darkModeName: "Dark", @@ -588,7 +677,7 @@ final class EnumBridgingTests: XCTestCase { groupUsingNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil, xcassetsPath: nil, templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, - tokensFileId: "file123", tokensCollectionName: nil, lightModeName: "Light", + tokensFile: nil, tokensFileId: "file123", tokensCollectionName: nil, lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -603,7 +692,7 @@ final class EnumBridgingTests: XCTestCase { groupUsingNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil, xcassetsPath: nil, templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, - tokensFileId: "file123", tokensCollectionName: "", lightModeName: "Light", + tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "", lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -618,7 +707,7 @@ final class EnumBridgingTests: XCTestCase { groupUsingNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil, xcassetsPath: nil, templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, - tokensFileId: "file123", tokensCollectionName: "Collection", lightModeName: nil, + tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "Collection", lightModeName: nil, darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -633,7 +722,7 @@ final class EnumBridgingTests: XCTestCase { groupUsingNamespace: nil, colorSwift: nil, swiftuiColorSwift: nil, xcassetsPath: nil, templatesPath: nil, syncCodeSyntax: nil, codeSyntaxTemplate: nil, - tokensFileId: "file123", tokensCollectionName: "Collection", lightModeName: "", + tokensFile: nil, tokensFileId: "file123", tokensCollectionName: "Collection", lightModeName: "", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -649,7 +738,7 @@ final class EnumBridgingTests: XCTestCase { mainRes: nil, mainSrc: nil, templatesPath: nil, xmlOutputFileName: nil, xmlDisabled: nil, composePackageName: nil, colorKotlin: nil, themeAttributes: nil, - tokensFileId: "file456", tokensCollectionName: nil, lightModeName: "Light", + tokensFile: nil, tokensFileId: "file456", tokensCollectionName: nil, lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -663,7 +752,7 @@ final class EnumBridgingTests: XCTestCase { mainRes: nil, mainSrc: nil, templatesPath: nil, xmlOutputFileName: nil, xmlDisabled: nil, composePackageName: nil, colorKotlin: nil, themeAttributes: nil, - tokensFileId: "file456", tokensCollectionName: "", lightModeName: "Light", + tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "", lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -677,7 +766,7 @@ final class EnumBridgingTests: XCTestCase { mainRes: nil, mainSrc: nil, templatesPath: nil, xmlOutputFileName: nil, xmlDisabled: nil, composePackageName: nil, colorKotlin: nil, themeAttributes: nil, - tokensFileId: "file456", tokensCollectionName: "Colors", lightModeName: nil, + tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "Colors", lightModeName: nil, darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -691,7 +780,7 @@ final class EnumBridgingTests: XCTestCase { mainRes: nil, mainSrc: nil, templatesPath: nil, xmlOutputFileName: nil, xmlDisabled: nil, composePackageName: nil, colorKotlin: nil, themeAttributes: nil, - tokensFileId: "file456", tokensCollectionName: "Colors", lightModeName: "", + tokensFile: nil, tokensFileId: "file456", tokensCollectionName: "Colors", lightModeName: "", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -705,7 +794,7 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnMissingTokensFileId() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - tokensFileId: nil, tokensCollectionName: "Collection", lightModeName: "Light", + tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -717,7 +806,7 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnEmptyTokensFileId() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - tokensFileId: "", tokensCollectionName: "Collection", lightModeName: "Light", + tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -729,7 +818,7 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnMissingTokensCollectionName() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - tokensFileId: "file789", tokensCollectionName: nil, lightModeName: "Light", + tokensFile: nil, tokensFileId: "file789", tokensCollectionName: nil, lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -741,7 +830,7 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnEmptyTokensCollectionName() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - tokensFileId: "file789", tokensCollectionName: "", lightModeName: "Light", + tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "", lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -753,7 +842,7 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnMissingLightModeName() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - tokensFileId: "file789", tokensCollectionName: "Collection", lightModeName: nil, + tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "Collection", lightModeName: nil, darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -765,7 +854,7 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryThrowsOnEmptyLightModeName() { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - tokensFileId: "file789", tokensCollectionName: "Collection", lightModeName: "", + tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "Collection", lightModeName: "", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -777,7 +866,7 @@ final class EnumBridgingTests: XCTestCase { func testFlutterColorsEntryValidatesSuccessfully() throws { let entry = Flutter.ColorsEntry( templatesPath: nil, output: nil, className: nil, - tokensFileId: "file789", tokensCollectionName: "Colors", lightModeName: "Light", + tokensFile: nil, tokensFileId: "file789", tokensCollectionName: "Colors", lightModeName: "Light", darkModeName: "Dark", lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -794,7 +883,7 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - tokensFileId: nil, tokensCollectionName: "Collection", lightModeName: "Light", + tokensFile: nil, tokensFileId: nil, tokensCollectionName: "Collection", lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -807,7 +896,7 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - tokensFileId: "", tokensCollectionName: "Collection", lightModeName: "Light", + tokensFile: nil, tokensFileId: "", tokensCollectionName: "Collection", lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -820,7 +909,7 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - tokensFileId: "fileABC", tokensCollectionName: nil, lightModeName: "Light", + tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: nil, lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -833,7 +922,7 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - tokensFileId: "fileABC", tokensCollectionName: "", lightModeName: "Light", + tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "", lightModeName: "Light", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -846,7 +935,7 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - tokensFileId: "fileABC", tokensCollectionName: "Collection", lightModeName: nil, + tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "Collection", lightModeName: nil, darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -859,7 +948,7 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - tokensFileId: "fileABC", tokensCollectionName: "Collection", lightModeName: "", + tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "Collection", lightModeName: "", darkModeName: nil, lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) @@ -872,7 +961,7 @@ final class EnumBridgingTests: XCTestCase { let entry = Web.ColorsEntry( output: nil, templatesPath: nil, outputDirectory: nil, cssFileName: nil, tsFileName: nil, jsonFileName: nil, - tokensFileId: "fileABC", tokensCollectionName: "Colors", lightModeName: "Light", + tokensFile: nil, tokensFileId: "fileABC", tokensCollectionName: "Colors", lightModeName: "Light", darkModeName: "Dark", lightHCModeName: nil, darkHCModeName: nil, primitivesModeName: nil, nameValidateRegexp: nil, nameReplaceRegexp: nil ) diff --git a/Tests/ExFigTests/Input/TokensFileSourceTests.swift b/Tests/ExFigTests/Input/TokensFileSourceTests.swift new file mode 100644 index 00000000..c9fddad6 --- /dev/null +++ b/Tests/ExFigTests/Input/TokensFileSourceTests.swift @@ -0,0 +1,681 @@ +// swiftlint:disable file_length type_body_length + +@testable import ExFigCLI +import XCTest + +final class TokensFileSourceTests: XCTestCase { + // MARK: - Flat Tokens (Task 8.1) + + func testParseFlatColorToken() throws { + let json = """ + { + "Brand": { + "Primary": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [0.231, 0.510, 0.965], + "hex": "#3b82f6" + } + } + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + let token = source.tokens["Brand.Primary"] + XCTAssertNotNil(token) + XCTAssertEqual(token?.type, "color") + + if case let .color(color) = token?.value { + XCTAssertEqual(color.colorSpace, "srgb") + XCTAssertEqual(color.components.count, 3) + XCTAssertEqual(color.components[0], 0.231, accuracy: 0.001) + XCTAssertEqual(color.alpha, 1.0) + XCTAssertEqual(color.hex, "#3b82f6") + } else { + XCTFail("Expected color value") + } + } + + func testParseColorWithAlpha() throws { + let json = """ + { + "Overlay": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [0, 0, 0], + "alpha": 0.5 + } + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + if case let .color(color) = source.tokens["Overlay"]?.value { + XCTAssertEqual(color.alpha, 0.5) + } else { + XCTFail("Expected color value") + } + } + + // MARK: - Nested Groups (Task 8.2) + + func testParseNestedGroups() throws { + let json = """ + { + "Colors": { + "$type": "color", + "Brand": { + "Primary": { + "$value": { + "colorSpace": "srgb", + "components": [1, 0, 0] + } + }, + "Secondary": { + "$value": { + "colorSpace": "srgb", + "components": [0, 1, 0] + } + } + } + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + XCTAssertNotNil(source.tokens["Colors.Brand.Primary"]) + XCTAssertNotNil(source.tokens["Colors.Brand.Secondary"]) + XCTAssertEqual(source.tokens["Colors.Brand.Primary"]?.type, "color") + XCTAssertEqual(source.tokens["Colors.Brand.Secondary"]?.type, "color") + } + + func testTypeInheritanceFromParentGroup() throws { + let json = """ + { + "Spacing": { + "$type": "dimension", + "Small": { + "$value": { "value": 4, "unit": "px" } + }, + "Medium": { + "$value": { "value": 16, "unit": "px" } + } + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + XCTAssertEqual(source.tokens["Spacing.Small"]?.type, "dimension") + XCTAssertEqual(source.tokens["Spacing.Medium"]?.type, "dimension") + } + + // MARK: - Dimension Parsing (Task 8.4) + + func testParseDimensionToken() throws { + let json = """ + { + "Size": { + "$type": "dimension", + "$value": { "value": 16, "unit": "px" } + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + if case let .dimension(dim) = source.tokens["Size"]?.value { + XCTAssertEqual(dim.value, 16) + XCTAssertEqual(dim.unit, "px") + } else { + XCTFail("Expected dimension value") + } + } + + // MARK: - Number Parsing + + func testParseNumberToken() throws { + let json = """ + { + "Weight": { + "$type": "number", + "$value": 700 + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + if case let .number(num) = source.tokens["Weight"]?.value { + XCTAssertEqual(num, 700) + } else { + XCTFail("Expected number value") + } + } + + // MARK: - Typography Parsing (Task 8.5) + + func testParseTypographyToken() throws { + let json = """ + { + "Heading": { + "$type": "typography", + "$value": { + "fontFamily": ["Inter"], + "fontSize": { "value": 32, "unit": "px" }, + "fontWeight": 700, + "lineHeight": 1.25 + } + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + if case let .typography(typo) = source.tokens["Heading"]?.value { + XCTAssertEqual(typo.fontFamily, ["Inter"]) + XCTAssertEqual(typo.fontSize?.value, 32) + XCTAssertEqual(typo.fontSize?.unit, "px") + XCTAssertEqual(typo.fontWeight, 700) + XCTAssertEqual(typo.lineHeight, 1.25) + } else { + XCTFail("Expected typography value") + } + } + + func testParseTypographyFontFamilyAsString() throws { + let json = """ + { + "Body": { + "$type": "typography", + "$value": { + "fontFamily": "Roboto", + "fontSize": { "value": 16, "unit": "px" } + } + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + if case let .typography(typo) = source.tokens["Body"]?.value { + XCTAssertEqual(typo.fontFamily, ["Roboto"]) + } else { + XCTFail("Expected typography value") + } + } + + // MARK: - FontFamily Token + + func testParseFontFamilyToken() throws { + let json = """ + { + "Primary": { + "$type": "fontFamily", + "$value": ["Inter", "sans-serif"] + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + if case let .fontFamily(families) = source.tokens["Primary"]?.value { + XCTAssertEqual(families, ["Inter", "sans-serif"]) + } else { + XCTFail("Expected fontFamily value") + } + } + + // MARK: - Alias Resolution (Task 8.7) + + func testAliasResolution() throws { + let json = """ + { + "Primitives": { + "Blue": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [0, 0, 1] + } + } + }, + "Semantic": { + "Primary": { + "$type": "color", + "$value": "{Primitives.Blue}" + } + } + } + """.utf8 + + var source = try TokensFileSource.parse(data: Data(json)) + try source.resolveAliases() + + if case let .color(color) = source.tokens["Semantic.Primary"]?.value { + XCTAssertEqual(color.components[2], 1.0) + } else { + XCTFail("Expected resolved color value") + } + } + + func testCircularAliasDetection() throws { + let json = """ + { + "A": { + "$type": "color", + "$value": "{B}" + }, + "B": { + "$type": "color", + "$value": "{A}" + } + } + """.utf8 + + var source = try TokensFileSource.parse(data: Data(json)) + XCTAssertThrowsError(try source.resolveAliases()) { error in + guard case TokensFileError.circularAlias = error else { + XCTFail("Expected circularAlias error, got \(error)") + return + } + } + } + + func testUnresolvedAliasError() throws { + let json = """ + { + "Token": { + "$type": "color", + "$value": "{NonExistent.Token}" + } + } + """.utf8 + + var source = try TokensFileSource.parse(data: Data(json)) + XCTAssertThrowsError(try source.resolveAliases()) { error in + guard case TokensFileError.unresolvedAlias = error else { + XCTFail("Expected unresolvedAlias error, got \(error)") + return + } + } + } + + // MARK: - $root Token (Task 8.8) + + func testRootTokenInGroup() throws { + let json = """ + { + "Brand": { + "$type": "color", + "$root": { + "$value": { + "colorSpace": "srgb", + "components": [0.5, 0.5, 0.5] + } + }, + "Light": { + "$value": { + "colorSpace": "srgb", + "components": [1, 1, 1] + } + } + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + XCTAssertNotNil(source.tokens["Brand.$root"]) + XCTAssertNotNil(source.tokens["Brand.Light"]) + + if case let .color(rootColor) = source.tokens["Brand.$root"]?.value { + XCTAssertEqual(rootColor.components[0], 0.5) + } else { + XCTFail("Expected root color value") + } + } + + // MARK: - $deprecated (Task 8.10) + + func testDeprecatedBoolean() throws { + let json = """ + { + "OldColor": { + "$type": "color", + "$value": { "colorSpace": "srgb", "components": [1, 0, 0] }, + "$deprecated": true + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + if case let .flag(flag) = source.tokens["OldColor"]?.deprecated { + XCTAssertTrue(flag) + } else { + XCTFail("Expected deprecated flag") + } + } + + func testDeprecatedString() throws { + let json = """ + { + "Legacy": { + "$type": "number", + "$value": 42, + "$deprecated": "Use NewToken instead" + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + if case let .message(msg) = source.tokens["Legacy"]?.deprecated { + XCTAssertEqual(msg, "Use NewToken instead") + } else { + XCTFail("Expected deprecated message") + } + } + + // MARK: - Non-sRGB Color Warning (Task 8.11) + + func testNonSRGBColorWarning() throws { + let json = """ + { + "Wide": { + "$type": "color", + "$value": { + "colorSpace": "display-p3", + "components": [1.0, 0.5, 0.0] + } + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + XCTAssertTrue(source.warnings.contains(where: { $0.contains("display-p3") })) + } + + // MARK: - Font Weight String Mapping (Task 8.12) + + func testFontWeightStringMapping() { + XCTAssertEqual(TokensFileSource.fontWeightFromString("thin"), 100) + XCTAssertEqual(TokensFileSource.fontWeightFromString("normal"), 400) + XCTAssertEqual(TokensFileSource.fontWeightFromString("bold"), 700) + XCTAssertEqual(TokensFileSource.fontWeightFromString("black"), 900) + XCTAssertEqual(TokensFileSource.fontWeightFromString("semi-bold"), 600) + XCTAssertNil(TokensFileSource.fontWeightFromString("unknown")) + } + + // MARK: - Validation (Task 8.13) + + func testMalformedJSON() { + let badData = Data("not json".utf8) + XCTAssertThrowsError(try TokensFileSource.parse(data: badData)) + } + + func testMissingValueWarning() throws { + let json = """ + { + "Broken": { + "$type": "color" + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + // "Broken" has no $value, so it's treated as a group, not a token + XCTAssertNil(source.tokens["Broken"]) + } + + func testInvalidColorObjectWarning() throws { + let json = """ + { + "Bad": { + "$type": "color", + "$value": { "components": [1, 0, 0] } + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + XCTAssertTrue(source.warnings.contains(where: { $0.contains("colorSpace") })) + } + + // MARK: - Unsupported Types Warning (Task 8.14) + + func testUnsupportedTypeWarning() throws { + let json = """ + { + "MyShadow": { + "$type": "shadow", + "$value": { "offsetX": 0, "offsetY": 2, "blur": 4, "color": "#000000" } + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + XCTAssertNil(source.tokens["MyShadow"]) + XCTAssertTrue(source.warnings.contains(where: { $0.contains("shadow") })) + } + + // MARK: - Description + + func testTokenDescription() throws { + let json = """ + { + "Token": { + "$type": "number", + "$value": 10, + "$description": "A test token" + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + XCTAssertEqual(source.tokens["Token"]?.description, "A test token") + } + + // MARK: - Model Mapping (Task 8.6) + + func testToColors() throws { + let json = """ + { + "Brand": { + "Primary": { + "$type": "color", + "$value": { + "colorSpace": "srgb", + "components": [0.2, 0.4, 0.8] + } + } + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + let colors = source.toColors() + XCTAssertEqual(colors.count, 1) + XCTAssertEqual(colors[0].name, "Brand/Primary") + XCTAssertEqual(colors[0].red, 0.2, accuracy: 0.001) + } + + func testToTextStyles() throws { + let json = """ + { + "Heading": { + "$type": "typography", + "$value": { + "fontFamily": ["Inter"], + "fontSize": { "value": 24, "unit": "px" }, + "lineHeight": 1.5 + } + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + let styles = source.toTextStyles() + XCTAssertEqual(styles.count, 1) + XCTAssertEqual(styles[0].name, "Heading") + XCTAssertEqual(styles[0].fontName, "Inter") + XCTAssertEqual(styles[0].fontSize, 24) + } + + func testToDimensionTokens() throws { + let json = """ + { + "Spacing": { + "$type": "dimension", + "$value": { "value": 8, "unit": "px" }, + "$description": "Small spacing" + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + let tokens = source.toDimensionTokens() + XCTAssertEqual(tokens.count, 1) + XCTAssertEqual(tokens[0].name, "Spacing") + XCTAssertEqual(tokens[0].value, 8) + XCTAssertEqual(tokens[0].tokenType, .dimension) + } + + func testToNumberTokens() throws { + let json = """ + { + "Opacity": { + "$type": "number", + "$value": 0.5 + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + let tokens = source.toNumberTokens() + XCTAssertEqual(tokens.count, 1) + XCTAssertEqual(tokens[0].value, 0.5) + XCTAssertEqual(tokens[0].tokenType, .number) + } + + // MARK: - Typography Font Weight as String + + func testTypographyFontWeightString() throws { + let json = """ + { + "Bold": { + "$type": "typography", + "$value": { + "fontFamily": ["Inter"], + "fontSize": { "value": 16, "unit": "px" }, + "fontWeight": "bold" + } + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + if case let .typography(typo) = source.tokens["Bold"]?.value { + XCTAssertEqual(typo.fontWeight, 700) + } else { + XCTFail("Expected typography value") + } + } + + // MARK: - Integration: Export Colors from .tokens.json (Task 9.6) + + func testExportColorsFromLocalTokensFile() throws { + // Create a temporary .tokens.json file + let json = """ + { + "Brand": { + "$type": "color", + "Primary": { + "$value": { + "colorSpace": "srgb", + "components": [0.2, 0.4, 0.8], + "alpha": 1.0, + "hex": "#3366CC" + }, + "$description": "Brand primary color" + }, + "Secondary": { + "$value": { + "colorSpace": "srgb", + "components": [0.8, 0.2, 0.4], + "alpha": 0.9 + } + } + }, + "Semantic": { + "$type": "color", + "Background": { + "$value": "{Brand.Primary}" + } + } + } + """ + + let tempDir = FileManager.default.temporaryDirectory + let tempFile = tempDir.appendingPathComponent("test-colors-\(UUID().uuidString).tokens.json") + try Data(json.utf8).write(to: tempFile) + defer { try? FileManager.default.removeItem(at: tempFile) } + + // Parse and resolve aliases — no Figma API needed + var source = try TokensFileSource.parse(fileAt: tempFile.path) + try source.resolveAliases() + + // Convert to ExFigCore Color models + let colors = source.toColors() + + XCTAssertEqual(colors.count, 3) + + let primary = try XCTUnwrap(colors.first(where: { $0.name == "Brand/Primary" })) + XCTAssertEqual(primary.red, 0.2, accuracy: 0.001) + XCTAssertEqual(primary.green, 0.4, accuracy: 0.001) + XCTAssertEqual(primary.blue, 0.8, accuracy: 0.001) + XCTAssertEqual(primary.alpha, 1.0) + + let secondary = try XCTUnwrap(colors.first(where: { $0.name == "Brand/Secondary" })) + XCTAssertEqual(secondary.alpha, 0.9, accuracy: 0.001) + + // Alias resolved to Brand.Primary's color value + let background = try XCTUnwrap(colors.first(where: { $0.name == "Semantic/Background" })) + XCTAssertEqual(background.red, 0.2, accuracy: 0.001) + XCTAssertEqual(background.green, 0.4, accuracy: 0.001) + XCTAssertEqual(background.blue, 0.8, accuracy: 0.001) + } + + func testExportColorsWithGroupFilter() throws { + let json = """ + { + "Brand": { + "$type": "color", + "Primary": { + "$value": { "colorSpace": "srgb", "components": [1, 0, 0] } + } + }, + "System": { + "$type": "color", + "Error": { + "$value": { "colorSpace": "srgb", "components": [0.8, 0, 0] } + } + } + } + """ + + let tempDir = FileManager.default.temporaryDirectory + let tempFile = tempDir.appendingPathComponent("test-filter-\(UUID().uuidString).tokens.json") + try Data(json.utf8).write(to: tempFile) + defer { try? FileManager.default.removeItem(at: tempFile) } + + var source = try TokensFileSource.parse(fileAt: tempFile.path) + try source.resolveAliases() + + let allColors = source.toColors() + XCTAssertEqual(allColors.count, 2) + + // Apply group filter — only "Brand" group + let prefix = "Brand".replacingOccurrences(of: ".", with: "/") + "/" + let filtered = allColors.filter { $0.name.hasPrefix(prefix) } + XCTAssertEqual(filtered.count, 1) + XCTAssertEqual(filtered.first?.name, "Brand/Primary") + } +} + +// swiftlint:enable file_length type_body_length diff --git a/Tests/ExFigTests/Output/W3CTokensExporterTests.swift b/Tests/ExFigTests/Output/W3CTokensExporterTests.swift index 0a3f58ff..96f4bac4 100644 --- a/Tests/ExFigTests/Output/W3CTokensExporterTests.swift +++ b/Tests/ExFigTests/Output/W3CTokensExporterTests.swift @@ -10,94 +10,132 @@ final class W3CTokensExporterTests: XCTestCase { // MARK: - Color Hex Conversion func testColorToHexRGB() { - // Given: A fully opaque red color - let exporter = W3CTokensExporter() + let exporter = W3CTokensExporter(version: .v2025) + let hex = exporter.colorToHex(r: 1.0, g: 0.0, b: 0.0) + XCTAssertEqual(hex, "#ff0000") + } - // When: Converting to hex - let hex = exporter.colorToHex(r: 1.0, g: 0.0, b: 0.0, a: 1.0) + func testColorToHexWhite() { + let exporter = W3CTokensExporter(version: .v2025) + let hex = exporter.colorToHex(r: 1.0, g: 1.0, b: 1.0) + XCTAssertEqual(hex, "#ffffff") + } - // Then: Should output #RRGGBB format - XCTAssertEqual(hex, "#ff0000") + func testColorToHexBlack() { + let exporter = W3CTokensExporter(version: .v2025) + let hex = exporter.colorToHex(r: 0.0, g: 0.0, b: 0.0) + XCTAssertEqual(hex, "#000000") } - func testColorToHexRGBA() { - // Given: A semi-transparent green color - let exporter = W3CTokensExporter() + func testColorToHexRoundsCorrectly() { + let exporter = W3CTokensExporter(version: .v2025) + let hex = exporter.colorToHex(r: 0.5, g: 0.5, b: 0.5) + XCTAssertEqual(hex, "#808080") + } + + // MARK: - Legacy Color Hex (v1) - // When: Converting to hex - let hex = exporter.colorToHex(r: 0.0, g: 1.0, b: 0.0, a: 0.5) + func testColorToHexLegacyOpaque() { + let exporter = W3CTokensExporter(version: .v1) + let hex = exporter.colorToHexLegacy(r: 1.0, g: 0.0, b: 0.0, a: 1.0) + XCTAssertEqual(hex, "#ff0000") + } - // Then: Should output #RRGGBBAA format + func testColorToHexLegacyWithAlpha() { + let exporter = W3CTokensExporter(version: .v1) + let hex = exporter.colorToHexLegacy(r: 0.0, g: 1.0, b: 0.0, a: 0.5) XCTAssertEqual(hex, "#00ff0080") } - func testColorToHexRoundsCorrectly() { - // Given: A color with fractional components - let exporter = W3CTokensExporter() + // MARK: - Color Object (v2025) - // When: Converting 127.5/255 (which should round to 128) - let hex = exporter.colorToHex(r: 0.5, g: 0.5, b: 0.5, a: 1.0) + func testColorToObjectOpaque() { + let exporter = W3CTokensExporter(version: .v2025) + let obj = exporter.colorToObject(r: 1.0, g: 1.0, b: 1.0, a: 1.0) - // Then: Should round correctly - XCTAssertEqual(hex, "#808080") + XCTAssertEqual(obj["colorSpace"] as? String, "srgb") + XCTAssertEqual(obj["components"] as? [Double], [1.0, 1.0, 1.0]) + XCTAssertEqual(obj["hex"] as? String, "#ffffff") + XCTAssertNil(obj["alpha"], "Alpha should be omitted when 1.0") } - func testColorToHexWhite() { - let exporter = W3CTokensExporter() - let hex = exporter.colorToHex(r: 1.0, g: 1.0, b: 1.0, a: 1.0) - XCTAssertEqual(hex, "#ffffff") - } + func testColorToObjectWithAlpha() { + let exporter = W3CTokensExporter(version: .v2025) + let obj = exporter.colorToObject(r: 0.231, g: 0.541, b: 0.8, a: 0.502) - func testColorToHexBlack() { - let exporter = W3CTokensExporter() - let hex = exporter.colorToHex(r: 0.0, g: 0.0, b: 0.0, a: 1.0) - XCTAssertEqual(hex, "#000000") + XCTAssertEqual(obj["colorSpace"] as? String, "srgb") + XCTAssertEqual(obj["components"] as? [Double], [0.231, 0.541, 0.8]) + XCTAssertEqual(obj["alpha"] as? Double, 0.502) + XCTAssertEqual(obj["hex"] as? String, "#3b8acc") } // MARK: - Variable Name to Hierarchy func testNameToHierarchySimple() { let exporter = W3CTokensExporter() - let path = exporter.nameToHierarchy("Background/Primary") - XCTAssertEqual(path, ["Background", "Primary"]) } func testNameToHierarchyNested() { let exporter = W3CTokensExporter() - let path = exporter.nameToHierarchy("Statement/Background/PrimaryPressed") - XCTAssertEqual(path, ["Statement", "Background", "PrimaryPressed"]) } func testNameToHierarchySingleLevel() { let exporter = W3CTokensExporter() - let path = exporter.nameToHierarchy("primary") - XCTAssertEqual(path, ["primary"]) } - // MARK: - W3C Token Structure + // MARK: - V2025 Color Export - func testExportColorsToW3CFormat() { - // Given: Colors with modes - let exporter = W3CTokensExporter() + func testExportColorsSingleModeV2025() { + let exporter = W3CTokensExporter(version: .v2025) + let colorsByMode: [String: [ExFigCore.Color]] = [ + "Light": [ + Color(name: "Background/Primary", red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0), + ], + ] + + let tokens = exporter.exportColors(colorsByMode: colorsByMode) + + guard let background = tokens["Background"] as? [String: Any], + let primary = background["Primary"] as? [String: Any] + else { + XCTFail("Expected nested structure Background/Primary") + return + } + + XCTAssertEqual(primary["$type"] as? String, "color") + + // $value should be a color object (not mode dict) + guard let value = primary["$value"] as? [String: Any] else { + XCTFail("Expected $value to be a color object") + return + } + XCTAssertEqual(value["colorSpace"] as? String, "srgb") + XCTAssertEqual(value["components"] as? [Double], [1.0, 1.0, 1.0]) + XCTAssertEqual(value["hex"] as? String, "#ffffff") + + // No modes extension for single mode + XCTAssertNil(primary["$extensions"], "Single mode should not have $extensions.com.exfig.modes") + } + + func testExportColorsMultiModeV2025() { + let exporter = W3CTokensExporter(version: .v2025) let colorsByMode: [String: [ExFigCore.Color]] = [ "Light": [ Color(name: "Background/Primary", red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0), ], "Dark": [ - Color(name: "Background/Primary", red: 0.1, green: 0.1, blue: 0.1, alpha: 1.0), + Color(name: "Background/Primary", red: 0.102, green: 0.102, blue: 0.102, alpha: 1.0), ], ] - // When: Exporting to W3C format let tokens = exporter.exportColors(colorsByMode: colorsByMode) - // Then: Should produce nested W3C structure guard let background = tokens["Background"] as? [String: Any], let primary = background["Primary"] as? [String: Any] else { @@ -105,17 +143,59 @@ final class W3CTokensExporterTests: XCTestCase { return } - XCTAssertEqual(primary["$type"] as? String, "color") - guard let value = primary["$value"] as? [String: String] else { - XCTFail("Expected $value to be a dictionary") + // $value is the default (first) mode + guard let value = primary["$value"] as? [String: Any] else { + XCTFail("Expected $value to be a color object") return } - XCTAssertEqual(value["Light"], "#ffffff") - XCTAssertEqual(value["Dark"], "#1a1a1a") + XCTAssertEqual(value["colorSpace"] as? String, "srgb") + XCTAssertNotNil(value["hex"]) + + // $extensions.com.exfig.modes should exist + guard let extensions = primary["$extensions"] as? [String: Any], + let comExfig = extensions["com.exfig"] as? [String: Any], + let modes = comExfig["modes"] as? [String: Any] + else { + XCTFail("Expected $extensions.com.exfig.modes") + return + } + + XCTAssertNotNil(modes["Light"]) + XCTAssertNotNil(modes["Dark"]) + + // Each mode value should be a color object + guard let darkMode = modes["Dark"] as? [String: Any] else { + XCTFail("Expected Dark mode to be a color object") + return + } + XCTAssertEqual(darkMode["colorSpace"] as? String, "srgb") + XCTAssertEqual(darkMode["hex"] as? String, "#1a1a1a") } - func testExportColorsWithDescription() { - let exporter = W3CTokensExporter() + func testExportColorsWithAlphaV2025() { + let exporter = W3CTokensExporter(version: .v2025) + let colorsByMode: [String: [ExFigCore.Color]] = [ + "Light": [ + Color(name: "overlay", red: 0.0, green: 0.0, blue: 0.0, alpha: 0.5), + ], + ] + + let tokens = exporter.exportColors(colorsByMode: colorsByMode) + + guard let overlay = tokens["overlay"] as? [String: Any], + let value = overlay["$value"] as? [String: Any] + else { + XCTFail("Expected overlay token with color object $value") + return + } + + XCTAssertEqual(value["alpha"] as? Double, 0.5) + // hex should be 6-digit (no alpha in hex per spec) + XCTAssertEqual(value["hex"] as? String, "#000000") + } + + func testExportColorsWithDescriptionV2025() { + let exporter = W3CTokensExporter(version: .v2025) let colorsByMode: [String: [ExFigCore.Color]] = [ "Light": [ Color(name: "primary", red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0), @@ -132,37 +212,110 @@ final class W3CTokensExporterTests: XCTestCase { XCTAssertEqual(primary["$description"] as? String, "Primary brand color") } - func testExportColorsSingleMode() { - // Given: Colors with only one mode - let exporter = W3CTokensExporter() + func testExportColorsEmptyDescriptionOmittedV2025() { + let exporter = W3CTokensExporter(version: .v2025) let colorsByMode: [String: [ExFigCore.Color]] = [ "Light": [ - Color(name: "accent", red: 0.0, green: 0.5, blue: 1.0, alpha: 1.0), + Color(name: "primary", red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0), + ], + ] + let descriptions = ["primary": " "] + + let tokens = exporter.exportColors(colorsByMode: colorsByMode, descriptions: descriptions) + + guard let primary = tokens["primary"] as? [String: Any] else { + XCTFail("Expected primary token") + return + } + XCTAssertNil(primary["$description"], "Whitespace-only description should be omitted") + } + + func testExportColorsWithMetadataV2025() { + let exporter = W3CTokensExporter(version: .v2025) + let colorsByMode: [String: [ExFigCore.Color]] = [ + "Light": [ + Color(name: "primary", red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0), + ], + ] + let metadata = ["primary": ColorTokenMetadata(variableId: "VariableID:123:456", fileId: "abc123")] + + let tokens = exporter.exportColors(colorsByMode: colorsByMode, metadata: metadata) + + guard let primary = tokens["primary"] as? [String: Any], + let extensions = primary["$extensions"] as? [String: Any], + let comExfig = extensions["com.exfig"] as? [String: Any] + else { + XCTFail("Expected $extensions.com.exfig") + return + } + + XCTAssertEqual(comExfig["variableId"] as? String, "VariableID:123:456") + XCTAssertEqual(comExfig["fileId"] as? String, "abc123") + } + + func testExportColorsMetadataMergesWithModesV2025() { + let exporter = W3CTokensExporter(version: .v2025) + let colorsByMode: [String: [ExFigCore.Color]] = [ + "Light": [ + Color(name: "primary", red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0), + ], + "Dark": [ + Color(name: "primary", red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0), + ], + ] + let metadata = ["primary": ColorTokenMetadata(variableId: "VariableID:123:456", fileId: "abc123")] + + let tokens = exporter.exportColors(colorsByMode: colorsByMode, metadata: metadata) + + guard let primary = tokens["primary"] as? [String: Any], + let extensions = primary["$extensions"] as? [String: Any], + let comExfig = extensions["com.exfig"] as? [String: Any] + else { + XCTFail("Expected $extensions.com.exfig") + return + } + + XCTAssertNotNil(comExfig["modes"], "Modes should be present") + XCTAssertNotNil(comExfig["variableId"], "Metadata should merge with modes") + XCTAssertNotNil(comExfig["fileId"], "FileId should merge with modes") + } + + // MARK: - V1 Color Export (Legacy) + + func testExportColorsV1Format() { + let exporter = W3CTokensExporter(version: .v1) + let colorsByMode: [String: [ExFigCore.Color]] = [ + "Light": [ + Color(name: "Background/Primary", red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0), + ], + "Dark": [ + Color(name: "Background/Primary", red: 0.1, green: 0.1, blue: 0.1, alpha: 1.0), ], ] - // When: Exporting let tokens = exporter.exportColors(colorsByMode: colorsByMode) - // Then: $value should be dict format (consistent with multi-mode) - guard let accent = tokens["accent"] as? [String: Any] else { - XCTFail("Expected accent token") + guard let background = tokens["Background"] as? [String: Any], + let primary = background["Primary"] as? [String: Any] + else { + XCTFail("Expected nested structure Background/Primary") return } - XCTAssertEqual(accent["$type"] as? String, "color") - // Always use dict format for consistency (mode name -> hex value) - guard let valueDict = accent["$value"] as? [String: String] else { - XCTFail("Expected $value to be a dictionary") + XCTAssertEqual(primary["$type"] as? String, "color") + guard let value = primary["$value"] as? [String: String] else { + XCTFail("Expected $value to be mode→hex dictionary") return } - XCTAssertEqual(valueDict["Light"], "#0080ff") + XCTAssertEqual(value["Light"], "#ffffff") + XCTAssertEqual(value["Dark"], "#1a1a1a") + XCTAssertNil(primary["$extensions"], "V1 should not have $extensions") } // MARK: - JSON Serialization func testSerializeToJSON() throws { - let exporter = W3CTokensExporter() + let exporter = W3CTokensExporter(version: .v2025) let colorsByMode: [String: [ExFigCore.Color]] = [ "Light": [ Color(name: "primary", red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0), @@ -176,10 +329,11 @@ final class W3CTokensExporterTests: XCTestCase { XCTAssertNotNil(jsonString) XCTAssertTrue(jsonString?.contains("\"$type\"") ?? false) XCTAssertTrue(jsonString?.contains("\"color\"") ?? false) + XCTAssertTrue(jsonString?.contains("\"colorSpace\"") ?? false) } func testSerializeToJSONCompact() throws { - let exporter = W3CTokensExporter() + let exporter = W3CTokensExporter(version: .v2025) let colorsByMode: [String: [ExFigCore.Color]] = [ "Light": [ Color(name: "primary", red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0), @@ -191,14 +345,13 @@ final class W3CTokensExporterTests: XCTestCase { let jsonString = String(data: jsonData, encoding: .utf8) XCTAssertNotNil(jsonString) - // Compact JSON should not have newlines XCTAssertFalse(jsonString?.contains("\n") ?? true) } // MARK: - Complex Hierarchy func testExportDeepNesting() { - let exporter = W3CTokensExporter() + let exporter = W3CTokensExporter(version: .v2025) let colorsByMode: [String: [ExFigCore.Color]] = [ "Light": [ Color(name: "UI/Button/Primary/Background", red: 0.0, green: 0.5, blue: 1.0, alpha: 1.0), @@ -220,10 +373,10 @@ final class W3CTokensExporterTests: XCTestCase { XCTAssertEqual(background["$type"] as? String, "color") } - // MARK: - Typography Export + // MARK: - Typography V2025 - func testExportTypographyBasic() { - let exporter = W3CTokensExporter() + func testExportTypographyV2025() { + let exporter = W3CTokensExporter(version: .v2025) let textStyles = [ TextStyle( name: "Heading/H1", @@ -246,45 +399,55 @@ final class W3CTokensExporterTests: XCTestCase { } XCTAssertEqual(h1["$type"] as? String, "typography") + + // Composite value guard let value = h1["$value"] as? [String: Any] else { XCTFail("Expected $value to be a dictionary") return } - XCTAssertEqual(value["fontFamily"] as? String, "Inter-Bold") - XCTAssertEqual(value["fontSize"] as? Double, 32) - XCTAssertEqual(value["lineHeight"] as? Double, 40) - XCTAssertEqual(value["letterSpacing"] as? Double, -0.5) - } + XCTAssertEqual(value["fontFamily"] as? [String], ["Inter-Bold"]) - func testExportTypographyWithTextCase() { - let exporter = W3CTokensExporter() - let textStyles = [ - TextStyle( - name: "Label/Uppercase", - fontName: "Inter-Medium", - fontSize: 12, - fontStyle: nil, - lineHeight: 16, - letterSpacing: 1.0, - textCase: .uppercased - ), - ] + // fontSize should be dimension object + guard let fontSize = value["fontSize"] as? [String: Any] else { + XCTFail("Expected fontSize to be dimension object") + return + } + XCTAssertEqual(fontSize["value"] as? Double, 32) + XCTAssertEqual(fontSize["unit"] as? String, "px") - let tokens = exporter.exportTypography(textStyles: textStyles) + // lineHeight should be ratio (40/32 = 1.25) + XCTAssertEqual(value["lineHeight"] as? Double, 1.25) - guard let label = tokens["Label"] as? [String: Any], - let uppercase = label["Uppercase"] as? [String: Any], - let value = uppercase["$value"] as? [String: Any] - else { - XCTFail("Expected nested structure Label/Uppercase") + // Sub-tokens + guard let fontFamilySub = h1["fontFamily"] as? [String: Any] else { + XCTFail("Expected fontFamily sub-token") + return + } + XCTAssertEqual(fontFamilySub["$type"] as? String, "fontFamily") + XCTAssertEqual(fontFamilySub["$value"] as? [String], ["Inter-Bold"]) + + guard let fontSizeSub = h1["fontSize"] as? [String: Any] else { + XCTFail("Expected fontSize sub-token") return } + XCTAssertEqual(fontSizeSub["$type"] as? String, "dimension") - XCTAssertEqual(value["textTransform"] as? String, "uppercase") + guard let lineHeightSub = h1["lineHeight"] as? [String: Any] else { + XCTFail("Expected lineHeight sub-token") + return + } + XCTAssertEqual(lineHeightSub["$type"] as? String, "number") + XCTAssertEqual(lineHeightSub["$value"] as? Double, 1.25) + + guard let letterSpacingSub = h1["letterSpacing"] as? [String: Any] else { + XCTFail("Expected letterSpacing sub-token") + return + } + XCTAssertEqual(letterSpacingSub["$type"] as? String, "dimension") } - func testExportTypographyNoLineHeight() { - let exporter = W3CTokensExporter() + func testExportTypographyNoLineHeightV2025() { + let exporter = W3CTokensExporter(version: .v2025) let textStyles = [ TextStyle( name: "Body", @@ -306,53 +469,63 @@ final class W3CTokensExporterTests: XCTestCase { return } - // lineHeight should be omitted when nil - XCTAssertNil(value["lineHeight"]) + XCTAssertNil(value["lineHeight"], "lineHeight should be omitted when nil") + XCTAssertNil(body["lineHeight"], "lineHeight sub-token should not exist when nil") + XCTAssertNil(body["letterSpacing"], "letterSpacing sub-token should not exist when 0") } - func testExportTypographyMultipleStyles() { - let exporter = W3CTokensExporter() + // MARK: - Typography V1 (Legacy) + + func testExportTypographyV1() { + let exporter = W3CTokensExporter(version: .v1) let textStyles = [ TextStyle( - name: "Text/Title", + name: "Heading/H1", fontName: "Inter-Bold", - fontSize: 24, - fontStyle: nil, - lineHeight: 32, - letterSpacing: 0, - textCase: .original - ), - TextStyle( - name: "Text/Body", - fontName: "Inter-Regular", - fontSize: 16, + fontSize: 32, fontStyle: nil, - lineHeight: 24, - letterSpacing: 0, + lineHeight: 40, + letterSpacing: -0.5, textCase: .original ), ] let tokens = exporter.exportTypography(textStyles: textStyles) - guard let text = tokens["Text"] as? [String: Any] else { - XCTFail("Expected Text group") + guard let heading = tokens["Heading"] as? [String: Any], + let h1 = heading["H1"] as? [String: Any] + else { + XCTFail("Expected nested structure Heading/H1") + return + } + + guard let value = h1["$value"] as? [String: Any] else { + XCTFail("Expected $value to be a dictionary") return } - XCTAssertNotNil(text["Title"]) - XCTAssertNotNil(text["Body"]) + // V1: fontFamily is a plain string, fontSize is a number + XCTAssertEqual(value["fontFamily"] as? String, "Inter-Bold") + XCTAssertEqual(value["fontSize"] as? Double, 32) + XCTAssertEqual(value["lineHeight"] as? Double, 40) + XCTAssertEqual(value["letterSpacing"] as? Double, -0.5) + + // No sub-tokens in v1 + XCTAssertNil(h1["fontFamily"], "V1 should not have sub-tokens") + XCTAssertNil(h1["fontSize"], "V1 should not have sub-tokens") } - // MARK: - Asset Export + // MARK: - Asset Export V2025 - func testExportAssetsBasic() { - let exporter = W3CTokensExporter() + func testExportAssetsV2025() { + let exporter = W3CTokensExporter(version: .v2025) let assets: [AssetToken] = [ AssetToken( name: "Icons/Navigation/ArrowLeft", url: "https://figma-api.s3.amazonaws.com/images/arrow-left.svg", - description: "Left arrow icon" + description: "Left arrow icon", + nodeId: "1:23", + fileId: "def456" ), ] @@ -366,16 +539,29 @@ final class W3CTokensExporterTests: XCTestCase { return } - XCTAssertEqual(arrowLeft["$type"] as? String, "asset") + // No $type for assets in v2025 + XCTAssertNil(arrowLeft["$type"], "V2025 should not have $type: asset") + + XCTAssertEqual(arrowLeft["$description"] as? String, "Left arrow icon") + + // Asset URL in $extensions.com.exfig.assetUrl + guard let extensions = arrowLeft["$extensions"] as? [String: Any], + let comExfig = extensions["com.exfig"] as? [String: Any] + else { + XCTFail("Expected $extensions.com.exfig") + return + } + XCTAssertEqual( - arrowLeft["$value"] as? String, + comExfig["assetUrl"] as? String, "https://figma-api.s3.amazonaws.com/images/arrow-left.svg" ) - XCTAssertEqual(arrowLeft["$description"] as? String, "Left arrow icon") + XCTAssertEqual(comExfig["nodeId"] as? String, "1:23") + XCTAssertEqual(comExfig["fileId"] as? String, "def456") } - func testExportAssetsNoDescription() { - let exporter = W3CTokensExporter() + func testExportAssetsNoDescriptionV2025() { + let exporter = W3CTokensExporter(version: .v2025) let assets: [AssetToken] = [ AssetToken(name: "icon", url: "https://example.com/icon.png", description: nil), ] @@ -390,8 +576,33 @@ final class W3CTokensExporterTests: XCTestCase { XCTAssertNil(icon["$description"]) } + // MARK: - Asset Export V1 (Legacy) + + func testExportAssetsV1() { + let exporter = W3CTokensExporter(version: .v1) + let assets: [AssetToken] = [ + AssetToken( + name: "Icons/Search", + url: "https://figma.com/images/search.svg", + description: nil + ), + ] + + let tokens = exporter.exportAssets(assets: assets) + + guard let icons = tokens["Icons"] as? [String: Any], + let search = icons["Search"] as? [String: Any] + else { + XCTFail("Expected Icons/Search") + return + } + + XCTAssertEqual(search["$type"] as? String, "asset") + XCTAssertEqual(search["$value"] as? String, "https://figma.com/images/search.svg") + } + func testExportAssetsMultiple() { - let exporter = W3CTokensExporter() + let exporter = W3CTokensExporter(version: .v2025) let assets: [AssetToken] = [ AssetToken(name: "UI/Button/Plus", url: "https://example.com/plus.svg", description: nil), AssetToken(name: "UI/Button/Minus", url: "https://example.com/minus.svg", description: nil), @@ -409,4 +620,333 @@ final class W3CTokensExporterTests: XCTestCase { XCTAssertNotNil(button["Plus"]) XCTAssertNotNil(button["Minus"]) } + + // MARK: - Token Aliases + + func testExportColorsWithAliasV2025() { + let exporter = W3CTokensExporter(version: .v2025) + + // Primitive color (resolved) + let colorsByMode: [String: [ExFigCore.Color]] = [ + "Light": [ + Color(name: "Primitives/Blue/500", red: 0.231, green: 0.510, blue: 0.965, alpha: 1.0), + Color(name: "Semantic/Primary", red: 0.231, green: 0.510, blue: 0.965, alpha: 1.0), + ], + ] + + // Semantic/Primary aliases Primitives/Blue/500 + let aliases: [String: [String: String]] = [ + "Semantic/Primary": ["light": "Primitives/Blue/500"], + ] + let modeKeyToName = ["light": "Light"] + + let tokens = exporter.exportColors( + colorsByMode: colorsByMode, + aliases: aliases, + modeKeyToName: modeKeyToName + ) + + // Primitive should have a color object + guard let primitives = tokens["Primitives"] as? [String: Any], + let blue = primitives["Blue"] as? [String: Any], + let b500 = blue["500"] as? [String: Any] + else { + XCTFail("Expected Primitives/Blue/500") + return + } + XCTAssertTrue(b500["$value"] is [String: Any], "Primitive should have color object $value") + + // Semantic should have an alias reference + guard let semantic = tokens["Semantic"] as? [String: Any], + let primary = semantic["Primary"] as? [String: Any] + else { + XCTFail("Expected Semantic/Primary") + return + } + XCTAssertEqual(primary["$value"] as? String, "{Primitives.Blue.500}") + } + + func testExportColorsMultiModeAliasV2025() { + let exporter = W3CTokensExporter(version: .v2025) + let colorsByMode: [String: [ExFigCore.Color]] = [ + "Light": [ + Color(name: "Primitives/Gray/50", red: 0.98, green: 0.98, blue: 0.98, alpha: 1.0), + Color(name: "Primitives/Gray/900", red: 0.1, green: 0.1, blue: 0.1, alpha: 1.0), + Color(name: "Background/Surface", red: 0.98, green: 0.98, blue: 0.98, alpha: 1.0), + ], + "Dark": [ + Color(name: "Primitives/Gray/50", red: 0.98, green: 0.98, blue: 0.98, alpha: 1.0), + Color(name: "Primitives/Gray/900", red: 0.1, green: 0.1, blue: 0.1, alpha: 1.0), + Color(name: "Background/Surface", red: 0.1, green: 0.1, blue: 0.1, alpha: 1.0), + ], + ] + + let aliases: [String: [String: String]] = [ + "Background/Surface": [ + "light": "Primitives/Gray/50", + "dark": "Primitives/Gray/900", + ], + ] + let modeKeyToName = ["light": "Light", "dark": "Dark"] + + let tokens = exporter.exportColors( + colorsByMode: colorsByMode, + aliases: aliases, + modeKeyToName: modeKeyToName + ) + + guard let background = tokens["Background"] as? [String: Any], + let surface = background["Surface"] as? [String: Any] + else { + XCTFail("Expected Background/Surface") + return + } + + // $value should be the default mode alias + XCTAssertEqual(surface["$value"] as? String, "{Primitives.Gray.50}") + + // $extensions.com.exfig.modes should contain per-mode aliases + guard let extensions = surface["$extensions"] as? [String: Any], + let comExfig = extensions["com.exfig"] as? [String: Any], + let modes = comExfig["modes"] as? [String: Any] + else { + XCTFail("Expected $extensions.com.exfig.modes") + return + } + XCTAssertEqual(modes["Light"] as? String, "{Primitives.Gray.50}") + XCTAssertEqual(modes["Dark"] as? String, "{Primitives.Gray.900}") + } + + func testExportColorsAliasDisabledInV1() { + let exporter = W3CTokensExporter(version: .v1) + let colorsByMode: [String: [ExFigCore.Color]] = [ + "Light": [ + Color(name: "Semantic/Primary", red: 0.231, green: 0.510, blue: 0.965, alpha: 1.0), + ], + ] + + // Even with aliases, v1 should output resolved hex values + let aliases: [String: [String: String]] = [ + "Semantic/Primary": ["light": "Primitives/Blue/500"], + ] + + let tokens = exporter.exportColors( + colorsByMode: colorsByMode, + aliases: aliases, + modeKeyToName: ["light": "Light"] + ) + + guard let semantic = tokens["Semantic"] as? [String: Any], + let primary = semantic["Primary"] as? [String: Any] + else { + XCTFail("Expected Semantic/Primary") + return + } + + // V1 should have hex dict, not alias reference + guard let value = primary["$value"] as? [String: String] else { + XCTFail("Expected $value to be mode→hex dictionary (v1)") + return + } + XCTAssertNotNil(value["Light"], "V1 should have hex value, not alias") + } + + // MARK: - W3C Path Conversion + + func testToW3CPath() { + XCTAssertEqual(W3CTokensExporter.toW3CPath("Primitives/Blue/500"), "Primitives.Blue.500") + XCTAssertEqual(W3CTokensExporter.toW3CPath("simple"), "simple") + } + + // MARK: - Dimension Token Export (Task 5.3) + + func testExportDimensionToken() { + let exporter = W3CTokensExporter(version: .v2025) + let token = NumberToken( + name: "Spacing/Medium", + value: 16, + tokenType: .dimension, + description: "Medium spacing", + variableId: "VariableID:1:10", + fileId: "abc123" + ) + + let result = exporter.exportDimensions(tokens: [token]) + + let spacing = result["Spacing"] as? [String: Any] + let medium = spacing?["Medium"] as? [String: Any] + XCTAssertNotNil(medium) + XCTAssertEqual(medium?["$type"] as? String, "dimension") + XCTAssertEqual(medium?["$description"] as? String, "Medium spacing") + + let value = medium?["$value"] as? [String: Any] + XCTAssertEqual(value?["value"] as? Double, 16) + XCTAssertEqual(value?["unit"] as? String, "px") + + let extensions = medium?["$extensions"] as? [String: Any] + let exfig = extensions?["com.exfig"] as? [String: Any] + XCTAssertEqual(exfig?["variableId"] as? String, "VariableID:1:10") + XCTAssertEqual(exfig?["fileId"] as? String, "abc123") + } + + func testExportDimensionTokenNoDescription() { + let exporter = W3CTokensExporter(version: .v2025) + let token = NumberToken( + name: "Radius/Small", + value: 4, + tokenType: .dimension, + description: nil, + variableId: "VariableID:1:11", + fileId: "abc123" + ) + + let result = exporter.exportDimensions(tokens: [token]) + let radius = result["Radius"] as? [String: Any] + let small = radius?["Small"] as? [String: Any] + XCTAssertNil(small?["$description"]) + + let value = small?["$value"] as? [String: Any] + XCTAssertEqual(value?["value"] as? Double, 4) + XCTAssertEqual(value?["unit"] as? String, "px") + } + + // MARK: - Number Token Export (Task 5.4) + + func testExportNumberToken() { + let exporter = W3CTokensExporter(version: .v2025) + let token = NumberToken( + name: "Font/Weight/Bold", + value: 700, + tokenType: .number, + description: "Bold weight", + variableId: "VariableID:1:20", + fileId: "abc123" + ) + + let result = exporter.exportNumbers(tokens: [token]) + + let font = result["Font"] as? [String: Any] + let weight = font?["Weight"] as? [String: Any] + let bold = weight?["Bold"] as? [String: Any] + XCTAssertNotNil(bold) + XCTAssertEqual(bold?["$type"] as? String, "number") + XCTAssertEqual(bold?["$value"] as? Double, 700) + XCTAssertEqual(bold?["$description"] as? String, "Bold weight") + } + + func testExportNumberTokenOpacity() { + let exporter = W3CTokensExporter(version: .v2025) + let token = NumberToken( + name: "Opacity/Disabled", + value: 0.5, + tokenType: .number, + description: nil, + variableId: "VariableID:1:21", + fileId: "abc123" + ) + + let result = exporter.exportNumbers(tokens: [token]) + let opacity = result["Opacity"] as? [String: Any] + let disabled = opacity?["Disabled"] as? [String: Any] + XCTAssertEqual(disabled?["$type"] as? String, "number") + XCTAssertEqual(disabled?["$value"] as? Double, 0.5) + XCTAssertNil(disabled?["$description"]) + } + + // MARK: - Scope to Token Type Mapping (Tasks 5.2, 5.5) + + func testScopesMappingDimension() { + XCTAssertEqual(NumberVariablesLoader.scopesToTokenType(["WIDTH_HEIGHT"]), .dimension) + XCTAssertEqual(NumberVariablesLoader.scopesToTokenType(["GAP"]), .dimension) + XCTAssertEqual(NumberVariablesLoader.scopesToTokenType(["CORNER_RADIUS"]), .dimension) + XCTAssertEqual(NumberVariablesLoader.scopesToTokenType(["FONT_SIZE"]), .dimension) + XCTAssertEqual(NumberVariablesLoader.scopesToTokenType(["LINE_HEIGHT"]), .dimension) + XCTAssertEqual(NumberVariablesLoader.scopesToTokenType(["LETTER_SPACING"]), .dimension) + XCTAssertEqual(NumberVariablesLoader.scopesToTokenType(["STROKE_FLOAT"]), .dimension) + XCTAssertEqual(NumberVariablesLoader.scopesToTokenType(["ALL_SCOPES"]), .dimension) + } + + func testScopesMappingNumber() { + XCTAssertEqual(NumberVariablesLoader.scopesToTokenType(["OPACITY"]), .number) + XCTAssertEqual(NumberVariablesLoader.scopesToTokenType(["FONT_WEIGHT"]), .number) + } + + func testScopesMappingEmptyDefaultsToNumber() { + XCTAssertEqual(NumberVariablesLoader.scopesToTokenType([]), .number) + } + + func testScopesMappingMixedDimensionWins() { + // If any scope is dimension, the type is dimension + XCTAssertEqual(NumberVariablesLoader.scopesToTokenType(["FONT_WEIGHT", "WIDTH_HEIGHT"]), .dimension) + XCTAssertEqual(NumberVariablesLoader.scopesToTokenType(["OPACITY", "GAP"]), .dimension) + } + + func testScopesMappingUnknownDefaultsToNumber() { + XCTAssertEqual(NumberVariablesLoader.scopesToTokenType(["UNKNOWN_SCOPE"]), .number) + } + + // MARK: - Unified Token Export (Task 7.3) + + func testUnifiedTokenExportMergesAllTypes() throws { + let exporter = W3CTokensExporter(version: .v2025) + + // Build color tokens + let colors: [String: [Color]] = [ + "Light": [Color(name: "Brand/Primary", platform: nil, red: 0.2, green: 0.4, blue: 0.9, alpha: 1.0)], + ] + let colorTokens = exporter.exportColors(colorsByMode: colors) + + // Build typography tokens + let textStyles = [TextStyle( + name: "Heading/H1", fontName: "Inter", fontSize: 32, + fontStyle: nil, lineHeight: 40, letterSpacing: 0, textCase: .original + )] + let typographyTokens = exporter.exportTypography(textStyles: textStyles) + + // Build dimension tokens + let dimensionTokens = exporter.exportDimensions(tokens: [ + NumberToken( + name: "Spacing/Medium", + value: 16, tokenType: .dimension, + description: nil, variableId: "v1", fileId: "f1" + ), + ]) + + // Build number tokens + let numberTokens = exporter.exportNumbers(tokens: [ + NumberToken( + name: "Opacity/Disabled", + value: 0.5, tokenType: .number, + description: nil, variableId: "v2", fileId: "f1" + ), + ]) + + // Merge all into unified output using production mergeTokens + var unified: [String: Any] = [:] + ExFigCommand.Download.DownloadTokens.mergeTokens(from: colorTokens, into: &unified) + ExFigCommand.Download.DownloadTokens.mergeTokens(from: typographyTokens, into: &unified) + ExFigCommand.Download.DownloadTokens.mergeTokens(from: dimensionTokens, into: &unified) + ExFigCommand.Download.DownloadTokens.mergeTokens(from: numberTokens, into: &unified) + + // Verify all token types present + let brand = unified["Brand"] as? [String: Any] + let primary = brand?["Primary"] as? [String: Any] + XCTAssertEqual(primary?["$type"] as? String, "color") + + let heading = unified["Heading"] as? [String: Any] + let h1 = heading?["H1"] as? [String: Any] + XCTAssertEqual(h1?["$type"] as? String, "typography") + + let spacing = unified["Spacing"] as? [String: Any] + let medium = spacing?["Medium"] as? [String: Any] + XCTAssertEqual(medium?["$type"] as? String, "dimension") + + let opacity = unified["Opacity"] as? [String: Any] + let disabled = opacity?["Disabled"] as? [String: Any] + XCTAssertEqual(disabled?["$type"] as? String, "number") + + // Verify serialization succeeds + let jsonData = try exporter.serializeToJSON(unified, compact: false) + XCTAssertGreaterThan(jsonData.count, 0) + } } diff --git a/Tests/ExFigTests/Subcommands/TokensConvertTests.swift b/Tests/ExFigTests/Subcommands/TokensConvertTests.swift new file mode 100644 index 00000000..664fddcc --- /dev/null +++ b/Tests/ExFigTests/Subcommands/TokensConvertTests.swift @@ -0,0 +1,236 @@ +@testable import ExFigCLI +import ExFigCore +import Foundation +import XCTest + +final class TokensConvertTests: XCTestCase { + // MARK: - Full Re-Export + + func testFullReExportProducesValidW3CJSON() throws { + let json = """ + { + "Brand": { + "$type": "color", + "Primary": { + "$value": { "colorSpace": "srgb", "components": [0.2, 0.4, 0.8], "alpha": 1.0 } + } + }, + "Spacing": { + "$type": "dimension", + "Small": { "$value": { "value": 4, "unit": "px" } } + }, + "Opacity": { "$type": "number", "$value": 0.5 }, + "Heading": { + "$type": "typography", + "$value": { "fontFamily": ["Inter"], "fontSize": { "value": 32, "unit": "px" } } + } + } + """.utf8 + + var source = try TokensFileSource.parse(data: Data(json)) + try source.resolveAliases() + + let allTokens = W3CTokensExporter(version: .v2025).exportAll(from: source) + let exporter = W3CTokensExporter(version: .v2025) + let jsonData = try exporter.serializeToJSON(allTokens, compact: false) + let parsed = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] + + XCTAssertNotNil(parsed) + XCTAssertNotNil(parsed?["Brand"]) + XCTAssertNotNil(parsed?["Spacing"]) + XCTAssertNotNil(parsed?["Opacity"]) + XCTAssertNotNil(parsed?["Heading"]) + } + + // MARK: - Group Filter + + func testGroupFilterOnlyIncludesMatchingTokens() throws { + let json = """ + { + "Brand": { + "$type": "color", + "Primary": { + "$value": { "colorSpace": "srgb", "components": [1, 0, 0] } + } + }, + "System": { + "$type": "color", + "Error": { + "$value": { "colorSpace": "srgb", "components": [0.8, 0, 0] } + } + } + } + """.utf8 + + var source = try TokensFileSource.parse(data: Data(json)) + try source.resolveAliases() + + let filtered = source.filteredByGroup("Brand") + let colors = filtered.toColors() + + XCTAssertEqual(colors.count, 1) + XCTAssertEqual(colors[0].name, "Brand/Primary") + } + + // MARK: - Type Filter + + func testTypeFilterColorOnly() throws { + let json = """ + { + "Primary": { + "$type": "color", + "$value": { "colorSpace": "srgb", "components": [1, 0, 0] } + }, + "Small": { + "$type": "dimension", + "$value": { "value": 4, "unit": "px" } + } + } + """.utf8 + + var source = try TokensFileSource.parse(data: Data(json)) + try source.resolveAliases() + + let colorOnly = source.filteredByTypes(["color"]) + XCTAssertEqual(colorOnly.tokens.count, 1) + XCTAssertEqual(colorOnly.toColors().count, 1) + XCTAssertEqual(colorOnly.toDimensionTokens().count, 0) + } + + func testTypeFilterMultipleTypes() throws { + let json = """ + { + "Primary": { + "$type": "color", + "$value": { "colorSpace": "srgb", "components": [1, 0, 0] } + }, + "Small": { + "$type": "dimension", + "$value": { "value": 4, "unit": "px" } + }, + "Weight": { + "$type": "number", + "$value": 700 + } + } + """.utf8 + + var source = try TokensFileSource.parse(data: Data(json)) + try source.resolveAliases() + + let filtered = source.filteredByTypes(["color", "dimension"]) + XCTAssertEqual(filtered.tokens.count, 2) + XCTAssertNotNil(filtered.tokens["Primary"]) + XCTAssertNotNil(filtered.tokens["Small"]) + XCTAssertNil(filtered.tokens["Weight"]) + } + + // MARK: - Combined Filters + + func testGroupAndTypeFilterCombined() throws { + let json = """ + { + "Brand": { + "Primary": { + "$type": "color", + "$value": { "colorSpace": "srgb", "components": [1, 0, 0] } + }, + "Radius": { + "$type": "dimension", + "$value": { "value": 8, "unit": "px" } + } + }, + "System": { + "Error": { + "$type": "color", + "$value": { "colorSpace": "srgb", "components": [0.8, 0, 0] } + } + } + } + """.utf8 + + var source = try TokensFileSource.parse(data: Data(json)) + try source.resolveAliases() + + // Group filter first, then type filter — should only get Brand colors + let filtered = source.filteredByGroup("Brand").filteredByTypes(["color"]) + XCTAssertEqual(filtered.tokens.count, 1) + XCTAssertNotNil(filtered.tokens["Brand.Primary"]) + XCTAssertNil(filtered.tokens["Brand.Radius"]) + XCTAssertNil(filtered.tokens["System.Error"]) + } + + // MARK: - W3C Version + + func testV1ExportUsesHexStrings() throws { + let json = """ + { + "Primary": { + "$type": "color", + "$value": { "colorSpace": "srgb", "components": [1, 0, 0] } + } + } + """.utf8 + + var source = try TokensFileSource.parse(data: Data(json)) + try source.resolveAliases() + + let exporter = W3CTokensExporter(version: .v1) + let colors = source.toColors() + let tokens = exporter.exportColors(colorsByMode: ["Default": colors]) + let jsonData = try exporter.serializeToJSON(tokens, compact: false) + let jsonString = String(data: jsonData, encoding: .utf8) + + // v1 uses hex strings, not color objects + XCTAssertNotNil(jsonString) + XCTAssertTrue(jsonString?.contains("#ff0000") == true) + } + + // MARK: - Compact Output + + func testCompactOutputIsMinified() throws { + let json = """ + { + "Primary": { + "$type": "color", + "$value": { "colorSpace": "srgb", "components": [1, 0, 0] } + } + } + """.utf8 + + var source = try TokensFileSource.parse(data: Data(json)) + try source.resolveAliases() + + let exporter = W3CTokensExporter(version: .v2025) + let colors = source.toColors() + let tokens = exporter.exportColors(colorsByMode: ["Default": colors]) + + let compactData = try exporter.serializeToJSON(tokens, compact: true) + let prettyData = try exporter.serializeToJSON(tokens, compact: false) + + // Compact should be shorter (no indentation or newlines) + XCTAssertLessThan(compactData.count, prettyData.count) + } + + // MARK: - Merge Tokens + + func testMergeTokensDeepMerge() { + var target: [String: Any] = [ + "Brand": [ + "Primary": ["$type": "color", "$value": "red"], + ] as [String: Any], + ] + + let source: [String: Any] = [ + "Brand": [ + "Secondary": ["$type": "color", "$value": "blue"], + ] as [String: Any], + ] + + W3CTokensExporter.mergeTokens(from: source, into: &target) + + let brand = target["Brand"] as? [String: Any] + XCTAssertNotNil(brand?["Primary"]) + XCTAssertNotNil(brand?["Secondary"]) + } +} diff --git a/Tests/ExFigTests/Subcommands/TokensInfoTests.swift b/Tests/ExFigTests/Subcommands/TokensInfoTests.swift new file mode 100644 index 00000000..805898dc --- /dev/null +++ b/Tests/ExFigTests/Subcommands/TokensInfoTests.swift @@ -0,0 +1,186 @@ +@testable import ExFigCLI +import XCTest + +final class TokensInfoTests: XCTestCase { + // MARK: - TokensFileSource Filtering & Statistics + + func testTokenCountsByType() throws { + let json = """ + { + "Brand": { + "$type": "color", + "Primary": { + "$value": { "colorSpace": "srgb", "components": [1, 0, 0] } + }, + "Secondary": { + "$value": { "colorSpace": "srgb", "components": [0, 1, 0] } + } + }, + "Spacing": { + "$type": "dimension", + "Small": { + "$value": { "value": 4, "unit": "px" } + } + }, + "Opacity": { + "$type": "number", + "$value": 0.5 + } + } + """.utf8 + + var source = try TokensFileSource.parse(data: Data(json)) + try source.resolveAliases() + + let counts = source.tokenCountsByType() + XCTAssertEqual(counts.count, 3) + + let colorCount = counts.first(where: { $0.type == "color" })?.count + XCTAssertEqual(colorCount, 2) + + let dimensionCount = counts.first(where: { $0.type == "dimension" })?.count + XCTAssertEqual(dimensionCount, 1) + + let numberCount = counts.first(where: { $0.type == "number" })?.count + XCTAssertEqual(numberCount, 1) + } + + func testTopLevelGroups() throws { + let json = """ + { + "Brand": { + "$type": "color", + "Primary": { + "$value": { "colorSpace": "srgb", "components": [1, 0, 0] } + }, + "Secondary": { + "$value": { "colorSpace": "srgb", "components": [0, 1, 0] } + } + }, + "Spacing": { + "$type": "dimension", + "Small": { + "$value": { "value": 4, "unit": "px" } + }, + "Medium": { + "$value": { "value": 8, "unit": "px" } + }, + "Large": { + "$value": { "value": 16, "unit": "px" } + } + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + let groups = source.topLevelGroups() + + XCTAssertEqual(groups.count, 2) + // Sorted by count descending + XCTAssertEqual(groups[0].name, "Spacing") + XCTAssertEqual(groups[0].count, 3) + XCTAssertEqual(groups[1].name, "Brand") + XCTAssertEqual(groups[1].count, 2) + } + + func testFilteredByGroup() throws { + let json = """ + { + "Brand": { + "$type": "color", + "Primary": { + "$value": { "colorSpace": "srgb", "components": [1, 0, 0] } + } + }, + "System": { + "$type": "color", + "Error": { + "$value": { "colorSpace": "srgb", "components": [0.8, 0, 0] } + } + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + let filtered = source.filteredByGroup("Brand") + + XCTAssertEqual(filtered.tokens.count, 1) + XCTAssertNotNil(filtered.tokens["Brand.Primary"]) + XCTAssertNil(filtered.tokens["System.Error"]) + } + + func testFilteredByTypes() throws { + let json = """ + { + "Primary": { + "$type": "color", + "$value": { "colorSpace": "srgb", "components": [1, 0, 0] } + }, + "Small": { + "$type": "dimension", + "$value": { "value": 4, "unit": "px" } + }, + "Weight": { + "$type": "number", + "$value": 700 + } + } + """.utf8 + + let source = try TokensFileSource.parse(data: Data(json)) + + let colorOnly = source.filteredByTypes(["color"]) + XCTAssertEqual(colorOnly.tokens.count, 1) + XCTAssertNotNil(colorOnly.tokens["Primary"]) + + let multiType = source.filteredByTypes(["color", "dimension"]) + XCTAssertEqual(multiType.tokens.count, 2) + } + + func testResolvedAliasCount() throws { + let json = """ + { + "Primitives": { + "Blue": { + "$type": "color", + "$value": { "colorSpace": "srgb", "components": [0, 0, 1] } + } + }, + "Semantic": { + "Primary": { + "$type": "color", + "$value": "{Primitives.Blue}" + }, + "Secondary": { + "$type": "color", + "$value": "{Primitives.Blue}" + } + } + } + """.utf8 + + var source = try TokensFileSource.parse(data: Data(json)) + XCTAssertEqual(source.aliasCount, 0) // not resolved yet + try source.resolveAliases() + XCTAssertEqual(source.aliasCount, 2) + } + + func testEmptyFile() throws { + let json = "{}".utf8 + var source = try TokensFileSource.parse(data: Data(json)) + try source.resolveAliases() + + XCTAssertEqual(source.tokens.count, 0) + XCTAssertEqual(source.tokenCountsByType().count, 0) + XCTAssertEqual(source.topLevelGroups().count, 0) + } + + func testFileNotFound() { + XCTAssertThrowsError(try TokensFileSource.parse(fileAt: "/nonexistent/tokens.json")) { error in + guard case TokensFileError.fileNotFound = error else { + XCTFail("Expected fileNotFound error, got \(error)") + return + } + } + } +} diff --git a/openspec/changes/w3c-tokens-v2/.openspec.yaml b/openspec/changes/archive/2026-02-25-w3c-tokens-v2/.openspec.yaml similarity index 100% rename from openspec/changes/w3c-tokens-v2/.openspec.yaml rename to openspec/changes/archive/2026-02-25-w3c-tokens-v2/.openspec.yaml diff --git a/openspec/changes/w3c-tokens-v2/design.md b/openspec/changes/archive/2026-02-25-w3c-tokens-v2/design.md similarity index 100% rename from openspec/changes/w3c-tokens-v2/design.md rename to openspec/changes/archive/2026-02-25-w3c-tokens-v2/design.md diff --git a/openspec/changes/w3c-tokens-v2/proposal.md b/openspec/changes/archive/2026-02-25-w3c-tokens-v2/proposal.md similarity index 100% rename from openspec/changes/w3c-tokens-v2/proposal.md rename to openspec/changes/archive/2026-02-25-w3c-tokens-v2/proposal.md diff --git a/openspec/changes/w3c-tokens-v2/specs/design-tokens-export/spec.md b/openspec/changes/archive/2026-02-25-w3c-tokens-v2/specs/design-tokens-export/spec.md similarity index 100% rename from openspec/changes/w3c-tokens-v2/specs/design-tokens-export/spec.md rename to openspec/changes/archive/2026-02-25-w3c-tokens-v2/specs/design-tokens-export/spec.md diff --git a/openspec/changes/w3c-tokens-v2/specs/tokens-file-source/spec.md b/openspec/changes/archive/2026-02-25-w3c-tokens-v2/specs/tokens-file-source/spec.md similarity index 100% rename from openspec/changes/w3c-tokens-v2/specs/tokens-file-source/spec.md rename to openspec/changes/archive/2026-02-25-w3c-tokens-v2/specs/tokens-file-source/spec.md diff --git a/openspec/changes/archive/2026-02-25-w3c-tokens-v2/tasks.md b/openspec/changes/archive/2026-02-25-w3c-tokens-v2/tasks.md new file mode 100644 index 00000000..967928cb --- /dev/null +++ b/openspec/changes/archive/2026-02-25-w3c-tokens-v2/tasks.md @@ -0,0 +1,88 @@ +## 1. W3C v2025.10 Color Format Compliance + +- [x] 1.1 Refactor `exportColors()` in `W3CTokensExporter.swift`: `$value` as color object (`colorSpace`, `components`, optional `alpha`/`hex`) instead of hex string +- [x] 1.2 Implement `colorToObject()` helper: RGBA → `{"colorSpace": "srgb", "components": [r,g,b], "alpha": a, "hex": "#rrggbb"}` (omit alpha when 1.0, hex is always 6-digit) +- [x] 1.3 Add `$extensions.com.exfig.modes` object for multi-mode colors (mode name → color object) +- [x] 1.4 Omit modes extension when only one mode is present +- [x] 1.5 Add `--w3c-version v1|v2025` flag to download commands (default: v2025, v1 preserves current hex string format) +- [x] 1.6 Update `W3CTokensExporterTests.swift` for new color object format (colorSpace, components, alpha, hex) +- [x] 1.7 Update `DownloadExportHelpers.swift` for new format and version flag + +## 2. Token Extensions & Descriptions + +- [x] 2.1 Add `$extensions.com.exfig` (reverse-domain key) with Figma metadata (variableId, fileId, nodeId) to color tokens +- [x] 2.2 Add `$extensions.com.exfig` to asset tokens (nodeId, fileId, assetUrl) +- [x] 2.3 Ensure `$extensions.com.exfig` merges mode data and Figma metadata when both present +- [x] 2.4 Add `$description` field from Figma variable descriptions (skip empty/whitespace-only) +- [x] 2.5 Write tests for extensions and descriptions output + +## 3. Token Aliases + +- [x] 3.1 Extend `ColorsVariablesLoader` to propagate alias paths alongside resolved values (new field on Color or wrapper type) +- [x] 3.2 Implement `"{Group.Token}"` alias syntax in `W3CTokensExporter.exportColors()` for semantic tokens +- [x] 3.3 Support per-mode aliases in `$extensions.com.exfig.modes` (each mode value can be an alias string) +- [x] 3.4 Disable alias output when `--w3c-version v1` is specified +- [x] 3.5 Write tests for alias output (direct alias, multi-mode aliases, v1 flag) + +## 4. Remove Invented Types + +- [x] 4.1 Replace `$type: "asset"` with `$extensions.com.exfig.assetUrl` in `exportAssets()` +- [x] 4.2 Preserve `$type: "asset"` behavior under `--w3c-version v1` +- [x] 4.3 Update asset export tests + +## 5. Dimension & Number Token Types (Phase 2) + +- [x] 5.1 Extend `ColorsVariablesLoader` (or create `DesignTokensLoader`) to load Figma number variables with scopes +- [x] 5.2 Implement scope-to-type mapping: spatial scopes → `dimension`, unitless scopes → `number` +- [x] 5.3 Add `exportDimensions()` method: `$value` as object `{"value": N, "unit": "px"}` (default unit "px") +- [x] 5.4 Add `exportNumbers()` method: `$value` as plain JSON number +- [x] 5.5 Map `FONT_WEIGHT` scope to `number` type (not dimension) +- [x] 5.6 Write tests for dimension and number token export (verify object format for dimension, plain number for number) + +## 6. Typography Decomposition (Phase 2) + +- [x] 6.1 Modify `exportTypography()` to emit individual sub-tokens alongside composite +- [x] 6.2 Use correct W3C `$type` and `$value` format for each sub-token: + - `fontFamily`: `$type: "fontFamily"`, `$value`: array of strings (e.g., `["Inter"]`) + - `fontWeight`: `$type: "fontWeight"`, `$value`: number (1–1000) or string alias + - `fontSize`: `$type: "dimension"`, `$value`: object `{"value": N, "unit": "px"}` + - `lineHeight`: `$type: "number"`, `$value`: plain number (ratio, not px) + - `letterSpacing`: `$type: "dimension"`, `$value`: object `{"value": N, "unit": "px"}` +- [x] 6.3 Convert lineHeight from px to ratio when Figma provides absolute px value (lineHeight / fontSize) +- [x] 6.4 Emit fontFamily as array format in composite `$value` (e.g., `["Inter"]` not `"Inter"`) +- [x] 6.5 Skip optional sub-tokens (lineHeight, letterSpacing) when not set +- [x] 6.6 Preserve composite-only behavior under `--w3c-version v1` +- [x] 6.7 Write tests for typography decomposition (verify dimension objects for fontSize, plain number for lineHeight) + +## 7. Unified Download Command (Phase 2) + +- [x] 7.1 Add `download tokens` subcommand (or expand existing `download colors --tokens`) for unified export +- [x] 7.2 Wire dimension + number + typography export into unified command +- [x] 7.3 Write integration test for unified token export + +## 8. TokensFileSource Parser (Phase 3) + +- [x] 8.1 Create `TokensFileSource.swift` with W3C DTCG JSON parser using JSONCodec (swift-yyjson) +- [x] 8.2 Implement nested group parsing with `$type` inheritance from parent groups +- [x] 8.3 Parse color `$value` objects: extract colorSpace, components, alpha, hex → convert to ExFigCore `Color` +- [x] 8.4 Parse dimension `$value` objects: extract value and unit +- [x] 8.5 Parse typography composite `$value`: fontFamily (string or array), fontSize (dimension object), fontWeight (number or string alias), lineHeight (number) +- [x] 8.6 Implement W3C token type → ExFigCore model mapping (color→Color, typography→TextStyle) +- [x] 8.7 Implement alias resolution with circular reference detection +- [x] 8.8 Support `$root` tokens within groups (referenced as `{group.$root}`) +- [x] 8.9 Support `$extends` group inheritance (deep merge from referenced group) +- [x] 8.10 Support `$deprecated` on tokens and groups (boolean or string, preserved as metadata) +- [x] 8.11 Handle non-sRGB color spaces: convert to sRGB or warn about gamut clipping +- [x] 8.12 Map fontWeight string aliases to numeric values ("bold"→700, "normal"→400, etc.) +- [x] 8.13 Implement validation: missing $value, invalid color object structure, invalid dimension object, malformed JSON +- [x] 8.14 Emit warnings for unsupported token types (cubicBezier, gradient, strokeStyle, border, transition, shadow, duration) +- [x] 8.15 Write comprehensive parser tests (flat tokens, nested groups, aliases, $root, $extends, validation errors) + +## 9. PKL Schema & Config Integration (Phase 3) + +- [x] 9.1 Add `Common.TokensFile` class to `Sources/ExFigCLI/Resources/Schemas/Common.pkl` with `path` and optional `groupFilter` +- [x] 9.2 Run `./bin/mise run codegen:pkl` to regenerate Swift types +- [x] 9.3 Add bridging in platform entry files for new `tokensFile` source type +- [x] 9.4 Integrate `TokensFileSource` into export pipeline (bypass Figma API when tokensFile source used) +- [x] 9.5 Write tests for PKL config with tokensFile source (with and without groupFilter) +- [x] 9.6 Write integration test: export colors from .tokens.json without FIGMA_PERSONAL_TOKEN diff --git a/openspec/changes/w3c-tokens-v2/tasks.md b/openspec/changes/w3c-tokens-v2/tasks.md deleted file mode 100644 index 26ee4f6f..00000000 --- a/openspec/changes/w3c-tokens-v2/tasks.md +++ /dev/null @@ -1,88 +0,0 @@ -## 1. W3C v2025.10 Color Format Compliance - -- [ ] 1.1 Refactor `exportColors()` in `W3CTokensExporter.swift`: `$value` as color object (`colorSpace`, `components`, optional `alpha`/`hex`) instead of hex string -- [ ] 1.2 Implement `colorToObject()` helper: RGBA → `{"colorSpace": "srgb", "components": [r,g,b], "alpha": a, "hex": "#rrggbb"}` (omit alpha when 1.0, hex is always 6-digit) -- [ ] 1.3 Add `$extensions.com.exfig.modes` object for multi-mode colors (mode name → color object) -- [ ] 1.4 Omit modes extension when only one mode is present -- [ ] 1.5 Add `--w3c-version v1|v2025` flag to download commands (default: v2025, v1 preserves current hex string format) -- [ ] 1.6 Update `W3CTokensExporterTests.swift` for new color object format (colorSpace, components, alpha, hex) -- [ ] 1.7 Update `DownloadExportHelpers.swift` for new format and version flag - -## 2. Token Extensions & Descriptions - -- [ ] 2.1 Add `$extensions.com.exfig` (reverse-domain key) with Figma metadata (variableId, fileId, nodeId) to color tokens -- [ ] 2.2 Add `$extensions.com.exfig` to asset tokens (nodeId, fileId, assetUrl) -- [ ] 2.3 Ensure `$extensions.com.exfig` merges mode data and Figma metadata when both present -- [ ] 2.4 Add `$description` field from Figma variable descriptions (skip empty/whitespace-only) -- [ ] 2.5 Write tests for extensions and descriptions output - -## 3. Token Aliases - -- [ ] 3.1 Extend `ColorsVariablesLoader` to propagate alias paths alongside resolved values (new field on Color or wrapper type) -- [ ] 3.2 Implement `"{Group.Token}"` alias syntax in `W3CTokensExporter.exportColors()` for semantic tokens -- [ ] 3.3 Support per-mode aliases in `$extensions.com.exfig.modes` (each mode value can be an alias string) -- [ ] 3.4 Disable alias output when `--w3c-version v1` is specified -- [ ] 3.5 Write tests for alias output (direct alias, multi-mode aliases, v1 flag) - -## 4. Remove Invented Types - -- [ ] 4.1 Replace `$type: "asset"` with `$extensions.com.exfig.assetUrl` in `exportAssets()` -- [ ] 4.2 Preserve `$type: "asset"` behavior under `--w3c-version v1` -- [ ] 4.3 Update asset export tests - -## 5. Dimension & Number Token Types (Phase 2) - -- [ ] 5.1 Extend `ColorsVariablesLoader` (or create `DesignTokensLoader`) to load Figma number variables with scopes -- [ ] 5.2 Implement scope-to-type mapping: spatial scopes → `dimension`, unitless scopes → `number` -- [ ] 5.3 Add `exportDimensions()` method: `$value` as object `{"value": N, "unit": "px"}` (default unit "px") -- [ ] 5.4 Add `exportNumbers()` method: `$value` as plain JSON number -- [ ] 5.5 Map `FONT_WEIGHT` scope to `number` type (not dimension) -- [ ] 5.6 Write tests for dimension and number token export (verify object format for dimension, plain number for number) - -## 6. Typography Decomposition (Phase 2) - -- [ ] 6.1 Modify `exportTypography()` to emit individual sub-tokens alongside composite -- [ ] 6.2 Use correct W3C `$type` and `$value` format for each sub-token: - - `fontFamily`: `$type: "fontFamily"`, `$value`: array of strings (e.g., `["Inter"]`) - - `fontWeight`: `$type: "fontWeight"`, `$value`: number (1–1000) or string alias - - `fontSize`: `$type: "dimension"`, `$value`: object `{"value": N, "unit": "px"}` - - `lineHeight`: `$type: "number"`, `$value`: plain number (ratio, not px) - - `letterSpacing`: `$type: "dimension"`, `$value`: object `{"value": N, "unit": "px"}` -- [ ] 6.3 Convert lineHeight from px to ratio when Figma provides absolute px value (lineHeight / fontSize) -- [ ] 6.4 Emit fontFamily as array format in composite `$value` (e.g., `["Inter"]` not `"Inter"`) -- [ ] 6.5 Skip optional sub-tokens (lineHeight, letterSpacing) when not set -- [ ] 6.6 Preserve composite-only behavior under `--w3c-version v1` -- [ ] 6.7 Write tests for typography decomposition (verify dimension objects for fontSize, plain number for lineHeight) - -## 7. Unified Download Command (Phase 2) - -- [ ] 7.1 Add `download tokens` subcommand (or expand existing `download colors --tokens`) for unified export -- [ ] 7.2 Wire dimension + number + typography export into unified command -- [ ] 7.3 Write integration test for unified token export - -## 8. TokensFileSource Parser (Phase 3) - -- [ ] 8.1 Create `TokensFileSource.swift` with W3C DTCG JSON parser using JSONCodec (swift-yyjson) -- [ ] 8.2 Implement nested group parsing with `$type` inheritance from parent groups -- [ ] 8.3 Parse color `$value` objects: extract colorSpace, components, alpha, hex → convert to ExFigCore `Color` -- [ ] 8.4 Parse dimension `$value` objects: extract value and unit -- [ ] 8.5 Parse typography composite `$value`: fontFamily (string or array), fontSize (dimension object), fontWeight (number or string alias), lineHeight (number) -- [ ] 8.6 Implement W3C token type → ExFigCore model mapping (color→Color, typography→TextStyle) -- [ ] 8.7 Implement alias resolution with circular reference detection -- [ ] 8.8 Support `$root` tokens within groups (referenced as `{group.$root}`) -- [ ] 8.9 Support `$extends` group inheritance (deep merge from referenced group) -- [ ] 8.10 Support `$deprecated` on tokens and groups (boolean or string, preserved as metadata) -- [ ] 8.11 Handle non-sRGB color spaces: convert to sRGB or warn about gamut clipping -- [ ] 8.12 Map fontWeight string aliases to numeric values ("bold"→700, "normal"→400, etc.) -- [ ] 8.13 Implement validation: missing $value, invalid color object structure, invalid dimension object, malformed JSON -- [ ] 8.14 Emit warnings for unsupported token types (cubicBezier, gradient, strokeStyle, border, transition, shadow, duration) -- [ ] 8.15 Write comprehensive parser tests (flat tokens, nested groups, aliases, $root, $extends, validation errors) - -## 9. PKL Schema & Config Integration (Phase 3) - -- [ ] 9.1 Add `Common.TokensFile` class to `Sources/ExFigCLI/Resources/Schemas/Common.pkl` with `path` and optional `groupFilter` -- [ ] 9.2 Run `./bin/mise run codegen:pkl` to regenerate Swift types -- [ ] 9.3 Add bridging in platform entry files for new `tokensFile` source type -- [ ] 9.4 Integrate `TokensFileSource` into export pipeline (bypass Figma API when tokensFile source used) -- [ ] 9.5 Write tests for PKL config with tokensFile source (with and without groupFilter) -- [ ] 9.6 Write integration test: export colors from .tokens.json without FIGMA_PERSONAL_TOKEN diff --git a/openspec/specs/design-tokens-export/spec.md b/openspec/specs/design-tokens-export/spec.md new file mode 100644 index 00000000..e2bac982 --- /dev/null +++ b/openspec/specs/design-tokens-export/spec.md @@ -0,0 +1,250 @@ +# Design Tokens Export Capability + +## ADDED Requirements + +### Requirement: W3C DTCG v2025.10 Color Format + +Each color token SHALL have a `$value` object conforming to the v2025.10 Color Module: an object with `colorSpace` +(string), `components` (array of numbers), optional `alpha` (number 0–1, defaults to 1), and optional `hex` (6-digit +sRGB fallback string). Multi-mode colors SHALL use `$extensions.com.exfig.modes` mapping mode names to color objects. +The `$value` field SHALL contain the default mode value. + +#### Scenario: Single-mode color token export + +- **GIVEN** a color named "Background/Primary" with RGBA (1.0, 1.0, 1.0, 1.0) in light mode only +- **WHEN** the color is exported in W3C v2025 format +- **THEN** the output token SHALL have `"$type": "color"` and `"$value"`: + ```json + { "colorSpace": "srgb", "components": [1, 1, 1], "hex": "#ffffff" } + ``` +- **AND** no `$extensions.com.exfig.modes` key SHALL be present + +#### Scenario: Multi-mode color token export + +- **GIVEN** a color named "Background/Primary" with values (1,1,1,1) in Light and (0.102,0.102,0.102,1) in Dark +- **WHEN** the color is exported in W3C v2025 format +- **THEN** `"$value"` SHALL be the default/first mode color object: + ```json + { "colorSpace": "srgb", "components": [1, 1, 1], "hex": "#ffffff" } + ``` +- **AND** `"$extensions.com.exfig"` SHALL contain `"modes"`: + ```json + { + "modes": { + "Light": { "colorSpace": "srgb", "components": [1, 1, 1], "hex": "#ffffff" }, + "Dark": { "colorSpace": "srgb", "components": [0.102, 0.102, 0.102], "hex": "#1a1a1a" } + } + } + ``` + +#### Scenario: Color with alpha transparency + +- **GIVEN** a color with RGBA values (0.231, 0.541, 0.800, 0.502) +- **WHEN** the color is exported in W3C v2025 format +- **THEN** `"$value"` SHALL be: + ```json + { "colorSpace": "srgb", "components": [0.231, 0.541, 0.8], "alpha": 0.502, "hex": "#3b8acc" } + ``` +- **AND** the `hex` field SHALL be 6 digits (no alpha in hex per spec), with alpha in the `alpha` field + +#### Scenario: Legacy v1 format preserved with flag + +- **GIVEN** a multi-mode color "Background/Primary" +- **WHEN** exported with `--w3c-version v1` +- **THEN** `"$value"` SHALL be a dict mapping mode names to hex values (current behavior) +- **AND** no `$extensions` key SHALL be present + +### Requirement: Token Extensions with Figma Metadata + +Each token SHALL include `$extensions.com.exfig` with Figma metadata when the source is a Figma file. The metadata +SHALL include `variableId` for variable-sourced tokens and `nodeId` plus `fileId` for component-sourced tokens. The +extension key uses reverse-domain notation (`com.exfig`) per the spec recommendation. + +#### Scenario: Variable-sourced color token with extensions + +- **GIVEN** a color variable with variableId "VariableID:123:456" from file "abc123" +- **WHEN** the color is exported in W3C v2025 format +- **THEN** `"$extensions"` SHALL contain: + ```json + { "com.exfig": { "variableId": "VariableID:123:456", "fileId": "abc123" } } + ``` + +#### Scenario: Component-sourced asset token with extensions + +- **GIVEN** an icon component with nodeId "1:23" and fileId "def456" +- **WHEN** the asset is exported in W3C v2025 format +- **THEN** `"$extensions"` SHALL contain: + ```json + { "com.exfig": { "nodeId": "1:23", "fileId": "def456" } } + ``` + +#### Scenario: Extensions merge with mode data + +- **GIVEN** a multi-mode color variable with variableId "VariableID:123:456" +- **WHEN** exported in W3C v2025 format +- **THEN** `"$extensions.com.exfig"` SHALL contain both `"modes"` and `"variableId"`/`"fileId"` keys + +### Requirement: Token Descriptions + +Tokens with Figma variable descriptions SHALL include a `$description` field. Empty or whitespace-only descriptions +MUST NOT produce a `$description` field. The `$description` value MUST be a plain JSON string per the spec. + +#### Scenario: Color with description + +- **GIVEN** a color variable with description "Primary brand color used for CTA buttons" +- **WHEN** the color is exported in W3C v2025 format +- **THEN** the token SHALL include `"$description": "Primary brand color used for CTA buttons"` + +#### Scenario: Color with empty description + +- **GIVEN** a color variable with description `""` +- **WHEN** the color is exported in W3C v2025 format +- **THEN** the token MUST NOT include a `"$description"` field + +#### Scenario: Typography style with description + +- **GIVEN** a text style with description "Heading level 1 for landing pages" +- **WHEN** the style is exported in W3C v2025 format +- **THEN** the token SHALL include `"$description": "Heading level 1 for landing pages"` + +### Requirement: Token Aliases + +Semantic tokens referencing primitive tokens SHALL use the W3C alias syntax `"{Group.Token}"` in their `$value` field. +The alias path SHALL use dot-separated group names matching the output token hierarchy. + +#### Scenario: Semantic color referencing a primitive + +- **GIVEN** a semantic variable "Semantic/Primary" aliasing primitive "Primitives/Blue/500" (hex `#3b82f6`) +- **WHEN** exported in W3C v2025 format +- **THEN** the primitive token SHALL have `"$value"` as a color object with `"hex": "#3b82f6"` +- **AND** the semantic token SHALL have `"$value": "{Primitives.Blue.500}"` + +#### Scenario: Multi-mode semantic color with alias per mode + +- **GIVEN** a semantic variable "Background/Surface" that aliases: + - Light mode: "Primitives/Gray/50" + - Dark mode: "Primitives/Gray/900" +- **WHEN** exported in W3C v2025 format +- **THEN** `"$value"` SHALL be `"{Primitives.Gray.50}"` (default mode alias) +- **AND** `"$extensions.com.exfig.modes"` SHALL contain: + ```json + { "Light": "{Primitives.Gray.50}", "Dark": "{Primitives.Gray.900}" } + ``` + +#### Scenario: Alias resolution disabled with v1 flag + +- **GIVEN** a semantic variable aliasing a primitive +- **WHEN** exported with `--w3c-version v1` +- **THEN** the `$value` SHALL contain the resolved hex value (current behavior) +- **AND** no alias reference syntax SHALL appear + +### Requirement: No Invented Token Types + +The exporter MUST NOT use `$type` values not defined in the W3C DTCG v2025.10 specification. Asset references +SHALL use `$extensions.com.exfig.assetUrl` instead of `$type: "asset"`. + +Valid `$type` values: `color`, `dimension`, `fontFamily`, `fontWeight`, `duration`, `cubicBezier`, `number`, +`strokeStyle`, `border`, `transition`, `shadow`, `gradient`, `typography`. + +Note: `fontStyle` is acknowledged in the spec as "still to be documented" and SHOULD NOT be used until formally defined. + +#### Scenario: Asset token exported without invented type + +- **GIVEN** an icon component "Icons/Search" with export URL "https://figma.com/images/..." +- **WHEN** the asset is exported in W3C v2025 format +- **THEN** the token MUST NOT include `"$type": "asset"` +- **AND** `"$extensions.com.exfig.assetUrl"` SHALL contain the export URL + +#### Scenario: Asset token with v1 flag preserves legacy type + +- **GIVEN** an icon component "Icons/Search" +- **WHEN** exported with `--w3c-version v1` +- **THEN** the token SHALL include `"$type": "asset"` and `"$value"` with the URL (current behavior) + +### Requirement: Dimension Tokens + +Figma number variables scoped to spatial properties SHALL export as `$type: "dimension"` with an object `$value` +containing `value` (number) and `unit` (string: `"px"` or `"rem"`). The unit is part of the value per v2025.10 spec, +NOT in `$extensions`. Figma variables don't carry unit info, so `"px"` is the default. + +Spatial scopes: `WIDTH_HEIGHT`, `GAP`, `CORNER_RADIUS`, `FONT_SIZE`, `LINE_HEIGHT`, `PARAGRAPH_SPACING`, +`PARAGRAPH_INDENT`. + +#### Scenario: Spacing variable exported as dimension + +- **GIVEN** a Figma number variable "Spacing/Medium" with value `16` and scope `["GAP"]` +- **WHEN** the variable is exported in W3C v2025 format +- **THEN** the token SHALL have `"$type": "dimension"` and: + ```json + "$value": { "value": 16, "unit": "px" } + ``` + +#### Scenario: Corner radius variable exported as dimension + +- **GIVEN** a Figma number variable "Radius/Large" with value `12` and scope `["CORNER_RADIUS"]` +- **WHEN** the variable is exported in W3C v2025 format +- **THEN** the token SHALL have `"$type": "dimension"` and: + ```json + "$value": { "value": 12, "unit": "px" } + ``` + +### Requirement: Number Tokens + +Figma number variables scoped to unitless properties SHALL export as `$type: "number"` with a plain numeric `$value`. +Variables with no scope or unknown scope SHALL default to `$type: "number"`. + +Unitless scopes: `OPACITY`, `FONT_WEIGHT`. + +#### Scenario: Opacity variable exported as number + +- **GIVEN** a Figma number variable "Opacity/Disabled" with value `0.4` and scope `["OPACITY"]` +- **WHEN** the variable is exported in W3C v2025 format +- **THEN** the token SHALL have `"$type": "number"` and `"$value": 0.4` + +#### Scenario: Variable with no scope defaults to number + +- **GIVEN** a Figma number variable "ZIndex/Modal" with value `100` and no scopes +- **WHEN** the variable is exported in W3C v2025 format +- **THEN** the token SHALL have `"$type": "number"` and `"$value": 100` + +### Requirement: Typography Decomposition + +Typography tokens SHALL export individual sub-tokens (`fontFamily`, `fontWeight`, `fontSize`, `lineHeight`, +`letterSpacing`) alongside the composite `typography` token. Sub-tokens SHALL use their respective W3C `$type` values +and correct `$value` formats per v2025.10. + +#### Scenario: Text style decomposed into sub-tokens + +- **GIVEN** a text style "Heading/H1" with font "Inter", weight 700, size 32, line height 1.25 +- **WHEN** the style is exported in W3C v2025 format +- **THEN** the output SHALL contain: + - `"Heading/H1"` with `"$type": "typography"` and composite `$value`: + ```json + { + "fontFamily": ["Inter"], + "fontSize": { "value": 32, "unit": "px" }, + "fontWeight": 700, + "lineHeight": 1.25 + } + ``` + - `"Heading/H1/fontFamily"` with `"$type": "fontFamily"` and `"$value": ["Inter"]` + - `"Heading/H1/fontWeight"` with `"$type": "fontWeight"` and `"$value": 700` + - `"Heading/H1/fontSize"` with `"$type": "dimension"` and `"$value": {"value": 32, "unit": "px"}` + - `"Heading/H1/lineHeight"` with `"$type": "number"` and `"$value": 1.25` + +Note: `fontFamily` uses array format per v2025.10 (single string or array of strings). `fontSize` is a dimension +object. `lineHeight` is a plain number (ratio, not px). `fontWeight` is a number (1–1000) or string alias per spec. + +#### Scenario: Text style without optional properties + +- **GIVEN** a text style "Body/Regular" with font "Inter", weight 400, size 16, no letter spacing, no explicit line height +- **WHEN** the style is exported in W3C v2025 format +- **THEN** `"letterSpacing"` and `"lineHeight"` sub-tokens MUST NOT be emitted +- **AND** the composite `typography` token SHALL omit `letterSpacing` and `lineHeight` from its `$value` + +#### Scenario: Typography export with v1 flag + +- **GIVEN** a text style "Heading/H1" +- **WHEN** exported with `--w3c-version v1` +- **THEN** only the composite `typography` token SHALL be emitted (current behavior) +- **AND** no individual sub-tokens SHALL be present diff --git a/openspec/specs/tokens-file-source/spec.md b/openspec/specs/tokens-file-source/spec.md new file mode 100644 index 00000000..3077ee04 --- /dev/null +++ b/openspec/specs/tokens-file-source/spec.md @@ -0,0 +1,380 @@ +# Tokens File Source Capability + +## ADDED Requirements + +### Requirement: Parse W3C DTCG Format + +The system SHALL parse `.tokens.json` files conforming to the W3C DTCG v2025.10 format. The parser SHALL support +nested token groups, `$type` inheritance from parent groups, and all token types defined in the design-tokens-export +capability (`color`, `dimension`, `number`, `typography`, `fontFamily`, `fontWeight`). + +#### Scenario: Parse a flat color token file + +- **GIVEN** a `.tokens.json` file containing: + ```json + { + "Brand": { + "Primary": { + "$type": "color", + "$value": { "colorSpace": "srgb", "components": [0.231, 0.510, 0.965], "hex": "#3b82f6" } + } + } + } + ``` +- **WHEN** the file is parsed +- **THEN** the parser SHALL produce one color token named "Brand/Primary" with RGB (0.231, 0.510, 0.965) + +#### Scenario: Parse nested groups with type inheritance + +- **GIVEN** a `.tokens.json` file containing: + ```json + { + "Colors": { + "$type": "color", + "Red": { + "500": { + "$value": { "colorSpace": "srgb", "components": [0.937, 0.267, 0.267], "hex": "#ef4444" } + } + }, + "Blue": { + "500": { + "$value": { "colorSpace": "srgb", "components": [0.231, 0.510, 0.965], "hex": "#3b82f6" } + } + } + } + } + ``` +- **WHEN** the file is parsed +- **THEN** the parser SHALL produce two color tokens: "Colors/Red/500" and "Colors/Blue/500" +- **AND** both tokens SHALL inherit `$type: "color"` from the parent group + +#### Scenario: Parse composite typography token + +- **GIVEN** a `.tokens.json` file containing a typography token with composite `$value`: + ```json + { + "Heading": { + "H1": { + "$type": "typography", + "$value": { + "fontFamily": ["Inter"], + "fontWeight": 700, + "fontSize": { "value": 32, "unit": "px" } + } + } + } + } + ``` +- **WHEN** the file is parsed +- **THEN** the parser SHALL produce a `TextStyle` with fontName "Inter", fontWeight 700, fontSize 32 + +#### Scenario: Parse dimension token + +- **GIVEN** a `.tokens.json` file containing: + ```json + { + "Spacing": { + "Medium": { + "$type": "dimension", + "$value": { "value": 16, "unit": "px" } + } + } + } + ``` +- **WHEN** the file is parsed +- **THEN** the parser SHALL produce a dimension token with value 16 and unit "px" + +#### Scenario: Parse token with extensions + +- **GIVEN** a `.tokens.json` file containing a token with `$extensions` +- **WHEN** the file is parsed +- **THEN** the parser SHALL preserve `$extensions` data as metadata on the parsed token +- **AND** `$extensions` SHALL NOT affect the token's resolved value + +#### Scenario: Parse token with $deprecated + +- **GIVEN** a `.tokens.json` file containing a token with `"$deprecated": true` +- **WHEN** the file is parsed +- **THEN** the parser SHALL mark the token as deprecated +- **AND** the token SHALL still be included in the output (deprecated is metadata, not exclusion) + +### Requirement: Group Features ($root, $extends, $deprecated) + +The parser SHALL support v2025.10 group features: `$root` tokens within groups, `$extends` for group inheritance, +and `$deprecated` on both tokens and groups. + +#### Scenario: Parse group with $root token + +- **GIVEN** a `.tokens.json` file containing: + ```json + { + "accent": { + "$root": { + "$type": "color", + "$value": { "colorSpace": "srgb", "components": [0.231, 0.510, 0.965], "hex": "#3b82f6" } + }, + "light": { + "$type": "color", + "$value": { "colorSpace": "srgb", "components": [0.745, 0.843, 0.992], "hex": "#bed7fd" } + } + } + } + ``` +- **WHEN** the file is parsed +- **THEN** the parser SHALL produce tokens "accent.$root" and "accent/light" +- **AND** aliases referencing `{accent.$root}` SHALL resolve to the root token + +#### Scenario: Parse group with $extends + +- **GIVEN** a `.tokens.json` file containing: + ```json + { + "base": { + "primary": { + "$type": "color", + "$value": { "colorSpace": "srgb", "components": [0, 0, 1], "hex": "#0000ff" } + } + }, + "brand": { + "$extends": "#/base", + "secondary": { + "$type": "color", + "$value": { "colorSpace": "srgb", "components": [1, 0, 0], "hex": "#ff0000" } + } + } + } + ``` +- **WHEN** the file is parsed +- **THEN** "brand" group SHALL inherit "primary" from "base" (deep merge) +- **AND** "brand/secondary" SHALL be added alongside inherited tokens + +### Requirement: Source Type in PKL Config + +A new `tokensFile` source type SHALL be available in the PKL schema alongside existing Figma sources. The source +SHALL accept a file path to a `.tokens.json` file and optional group filter. + +#### Scenario: PKL config with tokensFile source + +- **GIVEN** a PKL config with: + ```pkl + colors = new Listing { + new iOS.ColorsEntry { + source = new Common.TokensFile { + path = "./design-tokens.tokens.json" + groupFilter = "Colors/Brand" + } + } + } + ``` +- **WHEN** the config is evaluated +- **THEN** the system SHALL read colors from the specified `.tokens.json` file +- **AND** only tokens under the "Colors/Brand" group SHALL be included + +#### Scenario: PKL config with tokensFile and no group filter + +- **GIVEN** a PKL config with `tokensFile` source and no `groupFilter` +- **WHEN** the config is evaluated +- **THEN** all color tokens in the file SHALL be included regardless of group path + +#### Scenario: tokensFile source validation + +- **GIVEN** a PKL config with `tokensFile` source pointing to a non-existent path +- **WHEN** the config is validated +- **THEN** the system SHALL report an error: "Tokens file not found: {path}" + +### Requirement: Offline Workflow + +When `tokensFile` source is used, the system MUST NOT require Figma API access or the `FIGMA_PERSONAL_TOKEN` +environment variable. The export SHALL complete using only the local file. + +#### Scenario: Export without Figma token using tokensFile source + +- **GIVEN** a PKL config using only `tokensFile` sources +- **AND** the `FIGMA_PERSONAL_TOKEN` environment variable is not set +- **WHEN** `exfig colors -i config.pkl` is executed +- **THEN** the export SHALL complete successfully +- **AND** no Figma API calls SHALL be made + +#### Scenario: Mixed sources with and without Figma + +- **GIVEN** a PKL config with one `tokensFile` source and one `variablesColors` source +- **AND** the `FIGMA_PERSONAL_TOKEN` environment variable is set +- **WHEN** `exfig colors -i config.pkl` is executed +- **THEN** the `tokensFile` entry SHALL be processed from the local file +- **AND** the `variablesColors` entry SHALL be processed via Figma API +- **AND** both entries SHALL produce independent output + +### Requirement: Token Type Mapping + +The parser SHALL map W3C token types to ExFigCore domain models. Unmapped token types SHALL be skipped with a +warning message identifying the token name and unsupported type. + +| W3C Token Type | ExFigCore Model | `$value` Format | +| -------------- | --------------- | --------------------------------------------------------- | +| `color` | `Color` | Object: `{colorSpace, components, alpha?, hex?}` | +| `typography` | `TextStyle` | Object: `{fontFamily, fontSize, fontWeight, lineHeight?}` | +| `dimension` | (numeric value) | Object: `{value, unit}` — unit is `"px"` or `"rem"` | +| `number` | (numeric value) | Plain JSON number | +| `fontFamily` | (string value) | String or array of strings | +| `fontWeight` | (numeric value) | Number (1–1000) or string alias (e.g., "bold") | + +#### Scenario: Color token mapped to Color model + +- **GIVEN** a token: + ```json + { "$type": "color", "$value": { "colorSpace": "srgb", "components": [0.231, 0.510, 0.965], "hex": "#3b82f6" } } + ``` +- **WHEN** the token is mapped +- **THEN** the result SHALL be a `Color` with red=0.231, green=0.510, blue=0.965, alpha=1.0 + +#### Scenario: Color token with alpha + +- **GIVEN** a token: + ```json + { "$type": "color", "$value": { "colorSpace": "srgb", "components": [0.231, 0.510, 0.965], "alpha": 0.502 } } + ``` +- **WHEN** the token is mapped +- **THEN** the result SHALL be a `Color` with alpha=0.502 + +#### Scenario: Color token with non-sRGB color space + +- **GIVEN** a token with `"colorSpace": "display-p3"` +- **WHEN** the token is mapped +- **THEN** the parser SHALL convert from Display P3 to sRGB for ExFigCore `Color` (which uses sRGB internally) +- **OR** emit a warning if conversion is not supported: "Color space 'display-p3' converted to sRGB with possible gamut clipping" + +#### Scenario: Dimension token mapped + +- **GIVEN** a token: + ```json + { "$type": "dimension", "$value": { "value": 16, "unit": "px" } } + ``` +- **WHEN** the token is mapped +- **THEN** the result SHALL be a numeric value 16 with unit "px" + +#### Scenario: Typography token mapped to TextStyle + +- **GIVEN** a typography token with: + ```json + "$value": { "fontFamily": ["Inter"], "fontSize": { "value": 16, "unit": "px" }, "fontWeight": 400 } + ``` +- **WHEN** the token is mapped +- **THEN** the result SHALL be a `TextStyle` with fontName "Inter", fontSize 16.0 + +#### Scenario: fontWeight as string alias + +- **GIVEN** a token `{ "$type": "fontWeight", "$value": "bold" }` +- **WHEN** the token is mapped +- **THEN** the result SHALL resolve "bold" to numeric weight 700 + +#### Scenario: fontFamily as array + +- **GIVEN** a token `{ "$type": "fontFamily", "$value": ["Helvetica", "Arial", "sans-serif"] }` +- **WHEN** the token is mapped +- **THEN** the result SHALL use "Helvetica" as the primary font family + +#### Scenario: Unsupported token type produces warning + +- **GIVEN** a token `{ "$type": "cubicBezier", "$value": [0.42, 0, 0.58, 1] }` +- **WHEN** the token is mapped +- **THEN** the token SHALL be skipped +- **AND** a warning SHALL be emitted: "Unsupported token type 'cubicBezier' for token '{name}', skipping" + +### Requirement: Alias Resolution + +The parser SHALL resolve token alias references in `$value` fields. An alias is a string matching the pattern +`"{Group.Subgroup.Token}"`. Resolution SHALL follow the dot-separated path within the same token document. +Circular aliases SHALL be detected and reported as errors. + +#### Scenario: Resolve a direct alias + +- **GIVEN** a token file containing: + ```json + { + "Primitives": { + "Blue": { + "$type": "color", + "$value": { "colorSpace": "srgb", "components": [0.231, 0.510, 0.965], "hex": "#3b82f6" } + } + }, + "Semantic": { + "Primary": { "$type": "color", "$value": "{Primitives.Blue}" } + } + } + ``` +- **WHEN** the file is parsed and aliases are resolved +- **THEN** "Semantic/Primary" SHALL resolve to a `Color` with RGB (0.231, 0.510, 0.965) + +#### Scenario: Resolve a chained alias + +- **GIVEN** token A references token B, and token B references token C (a concrete value) +- **WHEN** the file is parsed and aliases are resolved +- **THEN** token A SHALL resolve to the concrete value of token C + +#### Scenario: Circular alias detected + +- **GIVEN** token A references token B, and token B references token A +- **WHEN** the file is parsed +- **THEN** the parser SHALL report an error: "Circular alias detected: {A} -> {B} -> {A}" +- **AND** no tokens SHALL be emitted for the circular chain + +#### Scenario: Alias to non-existent token + +- **GIVEN** a token with `$value: "{Missing.Token}"` +- **WHEN** the file is parsed +- **THEN** the parser SHALL report an error: "Unresolved alias '{Missing.Token}' in token '{name}'" + +#### Scenario: Alias to $root token + +- **GIVEN** a token with `$value: "{accent.$root}"` +- **WHEN** the file is parsed +- **THEN** the alias SHALL resolve to the `$root` token within the "accent" group + +### Requirement: Validation + +The parser SHALL validate the token file structure and report clear, actionable errors for malformed input. Validation +SHALL cover JSON syntax, required fields, and value format conformance. + +#### Scenario: Invalid JSON syntax + +- **GIVEN** a `.tokens.json` file with malformed JSON (e.g., trailing comma) +- **WHEN** the file is parsed +- **THEN** the parser SHALL report an error including the file path and JSON parse error location + +#### Scenario: Token with missing $value + +- **GIVEN** a token entry `{ "$type": "color" }` with no `$value` field +- **WHEN** the file is parsed +- **THEN** the parser SHALL report an error: "Token '{name}' has $type but missing $value" + +#### Scenario: Color token with invalid value structure + +- **GIVEN** a color token with `$value: "#3b82f6"` (plain string instead of object) +- **WHEN** the file is parsed +- **THEN** the parser SHALL report an error: "Invalid color value for token '{name}': expected object with colorSpace and components" + +#### Scenario: Color token with missing colorSpace + +- **GIVEN** a color token with `$value: { "components": [1, 0, 0] }` (missing colorSpace) +- **WHEN** the file is parsed +- **THEN** the parser SHALL report an error: "Color token '{name}' missing required 'colorSpace' in $value" + +#### Scenario: Dimension token with invalid value + +- **GIVEN** a dimension token with `$value: 16` (plain number instead of object) +- **WHEN** the file is parsed +- **THEN** the parser SHALL report an error: "Invalid dimension value for token '{name}': expected object with value and unit" + +#### Scenario: Empty token file + +- **GIVEN** a `.tokens.json` file containing `{}` +- **WHEN** the file is parsed +- **THEN** the parser SHALL produce zero tokens +- **AND** no error SHALL be reported (empty is valid) + +#### Scenario: Token file with mixed valid and invalid entries + +- **GIVEN** a `.tokens.json` file with 10 valid tokens and 2 invalid tokens +- **WHEN** the file is parsed +- **THEN** the 10 valid tokens SHALL be parsed successfully +- **AND** errors SHALL be reported for each of the 2 invalid tokens with their names and specific issues