diff --git a/src/adapters/output/index.ts b/src/adapters/output/index.ts index cd9e438..40c3f09 100644 --- a/src/adapters/output/index.ts +++ b/src/adapters/output/index.ts @@ -94,3 +94,39 @@ export { type AndroidXmlAdapterOptions, type AndroidDimensionUnit, } from './android-xml/index.js'; + +// Swift/Kotlin Output Adapter +export { + SwiftKotlinAdapter, + createSwiftKotlinAdapter, + type SwiftKotlinOutput, + type SwiftKotlinAdapterOptions, + colorToSwift, + colorToUIKit, + colorToSwiftHex, + colorToKotlin, + colorToKotlinArgb, + colorToKotlinHex, + dimensionToSwift, + dimensionToSwiftValue, + dimensionToKotlin, + dimensionToKotlinValue, + tokenNameToSwift, + tokenNameToKotlin, + ensureValidSwiftName, + ensureValidKotlinName, + fontWeightToSwift, + fontWeightToKotlin, + typographyToSwift, + typographyToKotlin, + shadowToSwift, + shadowToKotlin, + gradientToSwift, + gradientToKotlin, + swiftFileHeader, + kotlinFileHeader, + indent, + escapeSwiftString, + escapeKotlinString, + type SwiftDimensionUnit, +} from './swift-kotlin/index.js'; diff --git a/src/adapters/output/swift-kotlin/adapter.ts b/src/adapters/output/swift-kotlin/adapter.ts new file mode 100644 index 0000000..f9db360 --- /dev/null +++ b/src/adapters/output/swift-kotlin/adapter.ts @@ -0,0 +1,827 @@ +/** + * Swift/Kotlin Output Adapter + * + * Transforms normalized design tokens into Swift (iOS/SwiftUI) and Kotlin (Android/Compose) + * native constant files. + */ + +import type { + ThemeFile, + OutputAdapter, + OutputAdapterOptions, + Token, + TokenGroup, + ColorValue, + DimensionValue, + TypographyValue, + ShadowValue, + GradientValue, +} from '../../../schema/tokens.js'; + +import { + colorToSwift, + colorToUIKit, + colorToKotlin, + colorToKotlinHex, + dimensionToSwift, + dimensionToSwiftValue, + dimensionToKotlin, + tokenNameToSwift, + ensureValidSwiftName, + typographyToSwift, + typographyToKotlin, + shadowToSwift, + shadowToKotlin, + gradientToSwift, + gradientToKotlin, + swiftFileHeader, + kotlinFileHeader, +} from './converters.js'; + +// ============================================================================= +// Output Types +// ============================================================================= + +/** + * Swift/Kotlin output structure + */ +export interface SwiftKotlinOutput { + /** Swift file content (SwiftUI colors, dimensions, typography) */ + swift: string; + /** Swift UIKit extensions file content */ + swiftUIKit?: string; + /** Kotlin file content (Compose colors, dimensions, typography) */ + kotlin: string; + /** Separate files map */ + files: { + 'DesignTokens.swift': string; + 'DesignTokens+UIKit.swift'?: string; + 'DesignTokens.kt': string; + }; +} + +// ============================================================================= +// Adapter Options +// ============================================================================= + +export interface SwiftKotlinAdapterOptions extends OutputAdapterOptions { + /** Swift struct/enum name (default: 'DesignTokens') */ + swiftTypeName?: string; + /** Kotlin object name (default: 'DesignTokens') */ + kotlinObjectName?: string; + /** Kotlin package name (default: 'com.design.tokens') */ + kotlinPackage?: string; + /** Include UIKit extensions for Swift (default: false) */ + includeUIKit?: boolean; + /** Include description comments (default: true) */ + includeComments?: boolean; + /** Use hex color format instead of component format (default: false) */ + hexColors?: boolean; + /** Generate separate files per token category (default: false) */ + separateCategories?: boolean; +} + +// ============================================================================= +// Collected Token Types +// ============================================================================= + +interface CollectedColor { + name: string; + swiftValue: string; + uiKitValue: string; + kotlinValue: string; + description?: string; +} + +interface CollectedDimension { + name: string; + swiftValue: string; + kotlinValue: string; + rawValue: number; + isFontSize: boolean; + description?: string; +} + +interface CollectedTypography { + name: string; + swiftConfig: ReturnType; + kotlinConfig: ReturnType; + description?: string; +} + +interface CollectedShadow { + name: string; + swiftConfig: ReturnType; + kotlinConfig: ReturnType; + description?: string; +} + +interface CollectedGradient { + name: string; + swiftValue: string; + kotlinValue: string; + description?: string; +} + +// ============================================================================= +// Swift/Kotlin Adapter Implementation +// ============================================================================= + +/** + * Swift/Kotlin Output Adapter + * + * Generates native constant files: + * - Swift: SwiftUI Color, Font, and dimension constants + * - Kotlin: Compose Color, TextStyle, and dp/sp constants + */ +export class SwiftKotlinAdapter implements OutputAdapter { + readonly id = 'swift-kotlin'; + readonly name = 'Swift/Kotlin Native Constants Adapter'; + + /** + * Transform normalized theme to Swift/Kotlin output + */ + async transform( + theme: ThemeFile, + options: SwiftKotlinAdapterOptions = {} + ): Promise { + const { + mode, + swiftTypeName = 'DesignTokens', + kotlinObjectName = 'DesignTokens', + kotlinPackage = 'com.design.tokens', + includeUIKit = false, + includeComments = true, + hexColors = false, + } = options; + + // Collect tokens by type + const colors: CollectedColor[] = []; + const dimensions: CollectedDimension[] = []; + const typographyTokens: CollectedTypography[] = []; + const shadows: CollectedShadow[] = []; + const gradients: CollectedGradient[] = []; + + for (const collection of theme.collections) { + const targetMode = mode || collection.defaultMode; + const tokens = collection.tokens[targetMode]; + + if (!tokens) continue; + + this.collectTokens(tokens, [], { + colors, + dimensions, + typographyTokens, + shadows, + gradients, + hexColors, + }); + } + + // Generate Swift content + const swiftContent = this.generateSwift( + colors, + dimensions, + typographyTokens, + shadows, + gradients, + { + typeName: swiftTypeName, + themeName: theme.name, + includeComments, + } + ); + + // Generate Swift UIKit content (optional) + const swiftUIKitContent = includeUIKit + ? this.generateSwiftUIKit(colors, { + typeName: swiftTypeName, + themeName: theme.name, + includeComments, + }) + : undefined; + + // Generate Kotlin content + const kotlinContent = this.generateKotlin( + colors, + dimensions, + typographyTokens, + shadows, + gradients, + { + objectName: kotlinObjectName, + packageName: kotlinPackage, + themeName: theme.name, + includeComments, + } + ); + + // Build files object + const files: SwiftKotlinOutput['files'] = { + 'DesignTokens.swift': swiftContent, + 'DesignTokens.kt': kotlinContent, + }; + + if (swiftUIKitContent) { + files['DesignTokens+UIKit.swift'] = swiftUIKitContent; + } + + return { + swift: swiftContent, + swiftUIKit: swiftUIKitContent, + kotlin: kotlinContent, + files, + }; + } + + /** + * Recursively collect tokens from token groups + */ + private collectTokens( + group: TokenGroup, + path: string[], + context: { + colors: CollectedColor[]; + dimensions: CollectedDimension[]; + typographyTokens: CollectedTypography[]; + shadows: CollectedShadow[]; + gradients: CollectedGradient[]; + hexColors: boolean; + } + ): void { + for (const [key, value] of Object.entries(group)) { + if (key.startsWith('$')) continue; + + if (this.isToken(value)) { + const tokenPath = [...path, key]; + + switch (value.$type) { + case 'color': + this.collectColorToken(tokenPath, value, context); + break; + + case 'dimension': + this.collectDimensionToken(tokenPath, value, context); + break; + + case 'number': + this.collectNumberToken(tokenPath, value, context); + break; + + case 'typography': + this.collectTypographyToken(tokenPath, value, context); + break; + + case 'shadow': + this.collectShadowToken(tokenPath, value, context); + break; + + case 'gradient': + this.collectGradientToken(tokenPath, value, context); + break; + } + } else if (typeof value === 'object' && value !== null) { + this.collectTokens(value as TokenGroup, [...path, key], context); + } + } + } + + /** + * Collect a color token + */ + private collectColorToken( + path: string[], + token: Token, + context: { colors: CollectedColor[]; hexColors: boolean } + ): void { + const value = token.$value; + + if (typeof value === 'object' && value !== null && '$ref' in value) { + return; + } + + const colorValue = value as ColorValue; + const swiftName = ensureValidSwiftName(tokenNameToSwift(path)); + + context.colors.push({ + name: swiftName, + swiftValue: colorToSwift(colorValue), + uiKitValue: colorToUIKit(colorValue), + kotlinValue: context.hexColors ? colorToKotlinHex(colorValue) : colorToKotlin(colorValue), + description: token.$description, + }); + } + + /** + * Collect a dimension token + */ + private collectDimensionToken( + path: string[], + token: Token, + context: { dimensions: CollectedDimension[] } + ): void { + const value = token.$value; + + if (typeof value === 'object' && value !== null && '$ref' in value) { + return; + } + + const dimValue = value as DimensionValue; + const swiftName = ensureValidSwiftName(tokenNameToSwift(path)); + const isFontSize = + path.some((p) => p.toLowerCase().includes('font') || p.toLowerCase().includes('text')) || + path.some((p) => p.toLowerCase().includes('size')); + + context.dimensions.push({ + name: swiftName, + swiftValue: dimensionToSwift(dimValue), + kotlinValue: dimensionToKotlin(dimValue, isFontSize), + rawValue: dimensionToSwiftValue(dimValue), + isFontSize, + description: token.$description, + }); + } + + /** + * Collect a number token + */ + private collectNumberToken( + path: string[], + token: Token, + context: { dimensions: CollectedDimension[] } + ): void { + const value = token.$value; + + if (typeof value === 'object' && value !== null && '$ref' in value) { + return; + } + + const numValue = value as number; + const swiftName = ensureValidSwiftName(tokenNameToSwift(path)); + + context.dimensions.push({ + name: swiftName, + swiftValue: `CGFloat(${numValue})`, + kotlinValue: `${numValue}.dp`, + rawValue: numValue, + isFontSize: false, + description: token.$description, + }); + } + + /** + * Collect a typography token + */ + private collectTypographyToken( + path: string[], + token: Token, + context: { typographyTokens: CollectedTypography[] } + ): void { + const value = token.$value; + + if (typeof value === 'object' && value !== null && '$ref' in value) { + return; + } + + const typoValue = value as TypographyValue; + const swiftName = ensureValidSwiftName(tokenNameToSwift(path)); + + context.typographyTokens.push({ + name: swiftName, + swiftConfig: typographyToSwift(typoValue), + kotlinConfig: typographyToKotlin(typoValue), + description: token.$description, + }); + } + + /** + * Collect a shadow token + */ + private collectShadowToken( + path: string[], + token: Token, + context: { shadows: CollectedShadow[] } + ): void { + const value = token.$value; + + if (typeof value === 'object' && value !== null && '$ref' in value) { + return; + } + + // Handle both single shadow and array of shadows + const shadowArray = Array.isArray(value) ? value : [value]; + const firstShadow = shadowArray[0]; + + // Type guard for ShadowValue + if ( + !firstShadow || + typeof firstShadow !== 'object' || + !('offsetX' in firstShadow) || + !('offsetY' in firstShadow) + ) { + return; + } + + const shadowValue = firstShadow as ShadowValue; + const swiftName = ensureValidSwiftName(tokenNameToSwift(path)); + + context.shadows.push({ + name: swiftName, + swiftConfig: shadowToSwift(shadowValue), + kotlinConfig: shadowToKotlin(shadowValue), + description: token.$description, + }); + } + + /** + * Collect a gradient token + */ + private collectGradientToken( + path: string[], + token: Token, + context: { gradients: CollectedGradient[] } + ): void { + const value = token.$value; + + if (typeof value === 'object' && value !== null && '$ref' in value) { + return; + } + + const gradientValue = value as GradientValue; + const swiftName = ensureValidSwiftName(tokenNameToSwift(path)); + + context.gradients.push({ + name: swiftName, + swiftValue: gradientToSwift(gradientValue), + kotlinValue: gradientToKotlin(gradientValue), + description: token.$description, + }); + } + + /** + * Generate Swift file content + */ + private generateSwift( + colors: CollectedColor[], + dimensions: CollectedDimension[], + typography: CollectedTypography[], + shadows: CollectedShadow[], + gradients: CollectedGradient[], + options: { + typeName: string; + themeName: string; + includeComments: boolean; + } + ): string { + const lines: string[] = []; + + // File header + lines.push(swiftFileHeader(`${options.typeName}.swift`)); + + // Open struct + lines.push(`/// Design tokens generated from: ${options.themeName}`); + lines.push(`public enum ${options.typeName} {`); + lines.push(''); + + // Colors section + if (colors.length > 0) { + lines.push(' // MARK: - Colors'); + lines.push(' public enum Colors {'); + for (const color of colors) { + if (options.includeComments && color.description) { + lines.push(` /// ${color.description}`); + } + lines.push(` public static let ${color.name} = ${color.swiftValue}`); + } + lines.push(' }'); + lines.push(''); + } + + // Spacing/Dimensions section + if (dimensions.length > 0) { + const spacingDims = dimensions.filter((d) => !d.isFontSize); + const fontDims = dimensions.filter((d) => d.isFontSize); + + if (spacingDims.length > 0) { + lines.push(' // MARK: - Spacing'); + lines.push(' public enum Spacing {'); + for (const dim of spacingDims) { + if (options.includeComments && dim.description) { + lines.push(` /// ${dim.description}`); + } + lines.push(` public static let ${dim.name}: CGFloat = ${dim.rawValue}`); + } + lines.push(' }'); + lines.push(''); + } + + if (fontDims.length > 0) { + lines.push(' // MARK: - Font Sizes'); + lines.push(' public enum FontSizes {'); + for (const dim of fontDims) { + if (options.includeComments && dim.description) { + lines.push(` /// ${dim.description}`); + } + lines.push(` public static let ${dim.name}: CGFloat = ${dim.rawValue}`); + } + lines.push(' }'); + lines.push(''); + } + } + + // Typography section + if (typography.length > 0) { + lines.push(' // MARK: - Typography'); + lines.push(' public enum Typography {'); + for (const typo of typography) { + if (options.includeComments && typo.description) { + lines.push(` /// ${typo.description}`); + } + lines.push(` public static let ${typo.name} = Font.system(`); + lines.push(` size: ${typo.swiftConfig.size},`); + lines.push(` weight: ${typo.swiftConfig.weight}`); + lines.push(' )'); + } + lines.push(' }'); + lines.push(''); + } + + // Shadows section + if (shadows.length > 0) { + lines.push(' // MARK: - Shadows'); + lines.push(' public enum Shadows {'); + for (const shadow of shadows) { + if (options.includeComments && shadow.description) { + lines.push(` /// ${shadow.description}`); + } + lines.push(` public static let ${shadow.name} = ShadowStyle(`); + lines.push(` color: ${shadow.swiftConfig.color},`); + lines.push(` radius: ${shadow.swiftConfig.radius},`); + lines.push(` x: ${shadow.swiftConfig.x},`); + lines.push(` y: ${shadow.swiftConfig.y}`); + lines.push(' )'); + } + lines.push(' }'); + lines.push(''); + } + + // Gradients section + if (gradients.length > 0) { + lines.push(' // MARK: - Gradients'); + lines.push(' public enum Gradients {'); + for (const gradient of gradients) { + if (options.includeComments && gradient.description) { + lines.push(` /// ${gradient.description}`); + } + lines.push(` public static let ${gradient.name} = ${gradient.swiftValue}`); + } + lines.push(' }'); + lines.push(''); + } + + // Close struct + lines.push('}'); + + // Add ShadowStyle helper struct if shadows exist + if (shadows.length > 0) { + lines.push(''); + lines.push('/// Helper struct for shadow configuration'); + lines.push('public struct ShadowStyle {'); + lines.push(' public let color: Color'); + lines.push(' public let radius: CGFloat'); + lines.push(' public let x: CGFloat'); + lines.push(' public let y: CGFloat'); + lines.push(''); + lines.push(' public init(color: Color, radius: CGFloat, x: CGFloat, y: CGFloat) {'); + lines.push(' self.color = color'); + lines.push(' self.radius = radius'); + lines.push(' self.x = x'); + lines.push(' self.y = y'); + lines.push(' }'); + lines.push('}'); + lines.push(''); + lines.push('extension View {'); + lines.push(' public func shadow(_ style: ShadowStyle) -> some View {'); + lines.push(' self.shadow(color: style.color, radius: style.radius, x: style.x, y: style.y)'); + lines.push(' }'); + lines.push('}'); + } + + return lines.join('\n'); + } + + /** + * Generate Swift UIKit extensions file + */ + private generateSwiftUIKit( + colors: CollectedColor[], + options: { + typeName: string; + themeName: string; + includeComments: boolean; + } + ): string { + const lines: string[] = []; + + // File header + lines.push(swiftFileHeader(`${options.typeName}+UIKit.swift`, { imports: ['UIKit'] })); + + // Open extension + lines.push(`/// UIKit color extensions for: ${options.themeName}`); + lines.push(`public extension ${options.typeName}.Colors {`); + lines.push(''); + + // UIKit colors section + lines.push(' // MARK: - UIKit Colors'); + lines.push(' public enum UIKit {'); + for (const color of colors) { + if (options.includeComments && color.description) { + lines.push(` /// ${color.description}`); + } + lines.push(` public static let ${color.name} = ${color.uiKitValue}`); + } + lines.push(' }'); + + // Close extension + lines.push('}'); + + return lines.join('\n'); + } + + /** + * Generate Kotlin file content + */ + private generateKotlin( + colors: CollectedColor[], + dimensions: CollectedDimension[], + typography: CollectedTypography[], + shadows: CollectedShadow[], + gradients: CollectedGradient[], + options: { + objectName: string; + packageName: string; + themeName: string; + includeComments: boolean; + } + ): string { + const lines: string[] = []; + + // Collect imports + const imports = [ + 'androidx.compose.ui.graphics.Color', + 'androidx.compose.ui.unit.dp', + 'androidx.compose.ui.unit.sp', + ]; + + if (typography.length > 0) { + imports.push('androidx.compose.ui.text.TextStyle'); + imports.push('androidx.compose.ui.text.font.FontFamily'); + imports.push('androidx.compose.ui.text.font.FontWeight'); + } + + if (gradients.length > 0) { + imports.push('androidx.compose.ui.graphics.Brush'); + imports.push('androidx.compose.ui.geometry.Offset'); + } + + // File header + lines.push(kotlinFileHeader(options.packageName, { imports })); + + // Open object + lines.push(`/**`); + lines.push(` * Design tokens generated from: ${options.themeName}`); + lines.push(` */`); + lines.push(`object ${options.objectName} {`); + lines.push(''); + + // Colors section + if (colors.length > 0) { + lines.push(' // region Colors'); + lines.push(' object Colors {'); + for (const color of colors) { + if (options.includeComments && color.description) { + lines.push(` /** ${color.description} */`); + } + lines.push(` val ${color.name} = ${color.kotlinValue}`); + } + lines.push(' }'); + lines.push(' // endregion'); + lines.push(''); + } + + // Spacing/Dimensions section + if (dimensions.length > 0) { + const spacingDims = dimensions.filter((d) => !d.isFontSize); + const fontDims = dimensions.filter((d) => d.isFontSize); + + if (spacingDims.length > 0) { + lines.push(' // region Spacing'); + lines.push(' object Spacing {'); + for (const dim of spacingDims) { + if (options.includeComments && dim.description) { + lines.push(` /** ${dim.description} */`); + } + lines.push(` val ${dim.name} = ${dim.kotlinValue}`); + } + lines.push(' }'); + lines.push(' // endregion'); + lines.push(''); + } + + if (fontDims.length > 0) { + lines.push(' // region Font Sizes'); + lines.push(' object FontSizes {'); + for (const dim of fontDims) { + if (options.includeComments && dim.description) { + lines.push(` /** ${dim.description} */`); + } + lines.push(` val ${dim.name} = ${dim.kotlinValue}`); + } + lines.push(' }'); + lines.push(' // endregion'); + lines.push(''); + } + } + + // Typography section + if (typography.length > 0) { + lines.push(' // region Typography'); + lines.push(' object Typography {'); + for (const typo of typography) { + if (options.includeComments && typo.description) { + lines.push(` /** ${typo.description} */`); + } + lines.push(` val ${typo.name} = TextStyle(`); + lines.push(` fontSize = ${typo.kotlinConfig.fontSize},`); + lines.push(` fontWeight = ${typo.kotlinConfig.fontWeight},`); + lines.push(` fontFamily = ${typo.kotlinConfig.fontFamily}`); + if (typo.kotlinConfig.lineHeight) { + lines.push(` lineHeight = ${typo.kotlinConfig.lineHeight},`); + } + if (typo.kotlinConfig.letterSpacing) { + lines.push(` letterSpacing = ${typo.kotlinConfig.letterSpacing}`); + } + lines.push(' )'); + } + lines.push(' }'); + lines.push(' // endregion'); + lines.push(''); + } + + // Shadows section (as elevation values for Compose) + if (shadows.length > 0) { + lines.push(' // region Shadows'); + lines.push(' object Shadows {'); + for (const shadow of shadows) { + if (options.includeComments && shadow.description) { + lines.push(` /** ${shadow.description} */`); + } + lines.push(` val ${shadow.name}Elevation = ${shadow.kotlinConfig.elevation}`); + lines.push(` val ${shadow.name}Color = ${shadow.kotlinConfig.color}`); + } + lines.push(' }'); + lines.push(' // endregion'); + lines.push(''); + } + + // Gradients section + if (gradients.length > 0) { + lines.push(' // region Gradients'); + lines.push(' object Gradients {'); + for (const gradient of gradients) { + if (options.includeComments && gradient.description) { + lines.push(` /** ${gradient.description} */`); + } + lines.push(` val ${gradient.name} = ${gradient.kotlinValue}`); + } + lines.push(' }'); + lines.push(' // endregion'); + lines.push(''); + } + + // Close object + lines.push('}'); + + return lines.join('\n'); + } + + /** + * Check if value is a Token + */ + private isToken(value: unknown): value is Token { + return ( + typeof value === 'object' && value !== null && '$type' in value && '$value' in value + ); + } +} + +// ============================================================================= +// Factory Function +// ============================================================================= + +/** + * Create a new Swift/Kotlin adapter instance + */ +export function createSwiftKotlinAdapter(): SwiftKotlinAdapter { + return new SwiftKotlinAdapter(); +} diff --git a/src/adapters/output/swift-kotlin/converters.ts b/src/adapters/output/swift-kotlin/converters.ts new file mode 100644 index 0000000..3327b87 --- /dev/null +++ b/src/adapters/output/swift-kotlin/converters.ts @@ -0,0 +1,733 @@ +/** + * Swift/Kotlin Converters + * + * Utilities for converting design token values to Swift (iOS) and Kotlin (Android) + * native constant formats. + */ + +import type { + ColorValue, + DimensionValue, + TypographyValue, + FontWeightValue, + ShadowValue, + GradientValue, + GradientStop, +} from '../../../schema/tokens.js'; + +// ============================================================================= +// Swift Color Conversion +// ============================================================================= + +/** + * Convert a normalized color value to Swift UIColor/Color format. + * + * @param color - Color value with r, g, b, a in 0-1 range + * @returns Swift color initializer string + */ +export function colorToSwift(color: ColorValue): string { + // Format as Color(red:green:blue:opacity:) for SwiftUI + return `Color(red: ${color.r.toFixed(3)}, green: ${color.g.toFixed(3)}, blue: ${color.b.toFixed(3)}, opacity: ${color.a.toFixed(3)})`; +} + +/** + * Convert a normalized color value to Swift UIColor format (UIKit). + */ +export function colorToUIKit(color: ColorValue): string { + return `UIColor(red: ${color.r.toFixed(3)}, green: ${color.g.toFixed(3)}, blue: ${color.b.toFixed(3)}, alpha: ${color.a.toFixed(3)})`; +} + +/** + * Convert a normalized color value to Swift hex string format. + */ +export function colorToSwiftHex(color: ColorValue): string { + const r = Math.round(color.r * 255) + .toString(16) + .padStart(2, '0') + .toUpperCase(); + const g = Math.round(color.g * 255) + .toString(16) + .padStart(2, '0') + .toUpperCase(); + const b = Math.round(color.b * 255) + .toString(16) + .padStart(2, '0') + .toUpperCase(); + const a = Math.round(color.a * 255) + .toString(16) + .padStart(2, '0') + .toUpperCase(); + + return color.a === 1 ? `"#${r}${g}${b}"` : `"#${r}${g}${b}${a}"`; +} + +// ============================================================================= +// Kotlin Color Conversion +// ============================================================================= + +/** + * Convert a normalized color value to Kotlin Compose Color format. + * + * @param color - Color value with r, g, b, a in 0-1 range + * @returns Kotlin color initializer string + */ +export function colorToKotlin(color: ColorValue): string { + // Format as Color(red, green, blue, alpha) for Jetpack Compose + return `Color(${color.r.toFixed(3)}f, ${color.g.toFixed(3)}f, ${color.b.toFixed(3)}f, ${color.a.toFixed(3)}f)`; +} + +/** + * Convert a normalized color value to Kotlin ARGB Long format. + */ +export function colorToKotlinArgb(color: ColorValue): string { + const a = Math.round(color.a * 255); + const r = Math.round(color.r * 255); + const g = Math.round(color.g * 255); + const b = Math.round(color.b * 255); + + // Use unsigned long literal format + const argb = ((a << 24) | (r << 16) | (g << 8) | b) >>> 0; + return `0x${argb.toString(16).toUpperCase().padStart(8, '0')}`; +} + +/** + * Convert a normalized color value to Kotlin Color(0xAARRGGBB) format. + */ +export function colorToKotlinHex(color: ColorValue): string { + return `Color(${colorToKotlinArgb(color)})`; +} + +// ============================================================================= +// Dimension Conversion +// ============================================================================= + +/** + * Swift dimension units + */ +export type SwiftDimensionUnit = 'pt' | 'px'; + +/** + * Convert a dimension value to Swift CGFloat. + * Swift UI uses points (pt) as the base unit. + */ +export function dimensionToSwift(dim: DimensionValue): string { + switch (dim.unit) { + case 'px': + // 1px = 1pt at 1x scale + return `CGFloat(${dim.value})`; + case 'rem': + case 'em': + // Convert to points assuming 16pt base + return `CGFloat(${dim.value * 16})`; + case '%': + // Percentage needs context, return as multiplier + return `CGFloat(${dim.value / 100})`; + default: + return `CGFloat(${dim.value})`; + } +} + +/** + * Convert a dimension value to Swift numeric literal. + */ +export function dimensionToSwiftValue(dim: DimensionValue): number { + switch (dim.unit) { + case 'px': + return dim.value; + case 'rem': + case 'em': + return dim.value * 16; + case '%': + return dim.value / 100; + default: + return dim.value; + } +} + +/** + * Convert a dimension value to Kotlin Dp format. + * Jetpack Compose uses dp as the base unit. + */ +export function dimensionToKotlin(dim: DimensionValue, useSp: boolean = false): string { + const unit = useSp ? 'sp' : 'dp'; + switch (dim.unit) { + case 'px': + // 1px = 1dp at mdpi baseline + return `${dim.value}.${unit}`; + case 'rem': + case 'em': + // Convert to sp for text sizes + return `${dim.value * 16}.sp`; + case '%': + // Percentage - return as dp (will need context) + return `${dim.value}.${unit}`; + default: + return `${dim.value}.${unit}`; + } +} + +/** + * Convert a dimension value to Kotlin numeric value. + */ +export function dimensionToKotlinValue(dim: DimensionValue): number { + switch (dim.unit) { + case 'px': + return dim.value; + case 'rem': + case 'em': + return dim.value * 16; + case '%': + return dim.value; + default: + return dim.value; + } +} + +// ============================================================================= +// Name Conversion +// ============================================================================= + +/** + * Convert a token path to a valid Swift constant name. + * Swift uses camelCase for constants (though PascalCase for types). + * + * @param path - Array of path segments + * @param prefix - Optional prefix + * @returns Valid Swift identifier like "colorsPrimary500" + */ +export function tokenNameToSwift(path: string[], prefix: string = ''): string { + const segments = prefix ? [prefix, ...path] : path; + + // Flatten all segments into words + const words: string[] = []; + for (const segment of segments) { + // Split on hyphens, underscores, spaces, and camelCase boundaries + const parts = segment + .replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase to spaces + .replace(/[-_\s]+/g, ' ') // normalize separators to spaces + .replace(/[^a-zA-Z0-9 ]/g, '') // remove special chars + .trim() + .split(/\s+/) + .filter(Boolean); + + words.push(...parts); + } + + // Convert to camelCase + return words + .map((word, index) => { + if (index === 0) { + return word.toLowerCase(); + } + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + }) + .join(''); +} + +/** + * Convert a token path to a valid Kotlin constant name. + * Kotlin uses camelCase for properties, SCREAMING_SNAKE_CASE for const val. + * + * @param path - Array of path segments + * @param prefix - Optional prefix + * @param screaming - Use SCREAMING_SNAKE_CASE (default: false) + * @returns Valid Kotlin identifier + */ +export function tokenNameToKotlin( + path: string[], + prefix: string = '', + screaming: boolean = false +): string { + const segments = prefix ? [prefix, ...path] : path; + + // Flatten all segments into words + const words: string[] = []; + for (const segment of segments) { + const parts = segment + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/[-_\s]+/g, ' ') + .replace(/[^a-zA-Z0-9 ]/g, '') + .trim() + .split(/\s+/) + .filter(Boolean); + + words.push(...parts); + } + + if (screaming) { + return words.map((w) => w.toUpperCase()).join('_'); + } + + // Convert to camelCase + return words + .map((word, index) => { + if (index === 0) { + return word.toLowerCase(); + } + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + }) + .join(''); +} + +/** + * Ensure a name is a valid Swift identifier. + * Prepends underscore if name starts with a digit. + */ +export function ensureValidSwiftName(name: string): string { + if (/^\d/.test(name)) { + return `_${name}`; + } + // Swift reserved words + const reserved = [ + 'class', + 'struct', + 'enum', + 'protocol', + 'extension', + 'func', + 'var', + 'let', + 'if', + 'else', + 'switch', + 'case', + 'default', + 'for', + 'while', + 'repeat', + 'break', + 'continue', + 'return', + 'throw', + 'try', + 'catch', + 'import', + 'true', + 'false', + 'nil', + 'self', + 'Self', + 'super', + 'init', + 'deinit', + 'subscript', + 'typealias', + 'associatedtype', + 'inout', + 'static', + 'public', + 'private', + 'fileprivate', + 'internal', + 'open', + 'final', + 'override', + 'mutating', + 'nonmutating', + 'lazy', + 'weak', + 'unowned', + 'guard', + 'defer', + 'is', + 'as', + 'Any', + 'Type', + ]; + if (reserved.includes(name)) { + return `\`${name}\``; + } + return name; +} + +/** + * Ensure a name is a valid Kotlin identifier. + */ +export function ensureValidKotlinName(name: string): string { + if (/^\d/.test(name)) { + return `_${name}`; + } + // Kotlin reserved words + const reserved = [ + 'as', + 'break', + 'class', + 'continue', + 'do', + 'else', + 'false', + 'for', + 'fun', + 'if', + 'in', + 'interface', + 'is', + 'null', + 'object', + 'package', + 'return', + 'super', + 'this', + 'throw', + 'true', + 'try', + 'typealias', + 'typeof', + 'val', + 'var', + 'when', + 'while', + ]; + if (reserved.includes(name)) { + return `\`${name}\``; + } + return name; +} + +// ============================================================================= +// Typography Conversion +// ============================================================================= + +/** + * Convert font weight to Swift UIFont.Weight. + */ +export function fontWeightToSwift(weight: FontWeightValue): string { + const numericWeight = typeof weight === 'number' ? weight : fontWeightToNumeric(weight); + + if (numericWeight <= 100) return '.ultraLight'; + if (numericWeight <= 200) return '.thin'; + if (numericWeight <= 300) return '.light'; + if (numericWeight <= 400) return '.regular'; + if (numericWeight <= 500) return '.medium'; + if (numericWeight <= 600) return '.semibold'; + if (numericWeight <= 700) return '.bold'; + if (numericWeight <= 800) return '.heavy'; + return '.black'; +} + +/** + * Convert font weight to Kotlin FontWeight. + */ +export function fontWeightToKotlin(weight: FontWeightValue): string { + const numericWeight = typeof weight === 'number' ? weight : fontWeightToNumeric(weight); + return `FontWeight.W${Math.round(numericWeight / 100) * 100}`; +} + +/** + * Convert keyword font weight to numeric. + */ +function fontWeightToNumeric(weight: string): number { + const weightMap: Record = { + thin: 100, + extralight: 200, + light: 300, + normal: 400, + medium: 500, + semibold: 600, + bold: 700, + extrabold: 800, + black: 900, + }; + return weightMap[weight] ?? 400; +} + +/** + * Convert typography value to Swift Font configuration. + */ +export function typographyToSwift(typography: TypographyValue): { + size: number; + weight: string; + family: string; + lineHeight?: number; + letterSpacing?: number; +} { + const fontFamily = + typography.fontFamily.length > 0 + ? typography.fontFamily[0] + .replace(/['"]/g, '') + .replace(/,.*$/, '') + .trim() + : 'system'; + + const lineHeight = + typeof typography.lineHeight === 'number' + ? typography.fontSize.value * typography.lineHeight + : typography.lineHeight + ? dimensionToSwiftValue(typography.lineHeight) + : undefined; + + const letterSpacing = typography.letterSpacing + ? dimensionToSwiftValue(typography.letterSpacing) + : undefined; + + return { + size: dimensionToSwiftValue(typography.fontSize), + weight: fontWeightToSwift(typography.fontWeight), + family: fontFamily, + lineHeight, + letterSpacing, + }; +} + +/** + * Convert typography value to Kotlin TextStyle configuration. + */ +export function typographyToKotlin(typography: TypographyValue): { + fontSize: string; + fontWeight: string; + fontFamily: string; + lineHeight?: string; + letterSpacing?: string; +} { + const fontFamily = + typography.fontFamily.length > 0 + ? typography.fontFamily[0] + .replace(/['"]/g, '') + .replace(/,.*$/, '') + .trim() + : 'Default'; + + // Map common font families to Kotlin FontFamily + let kotlinFontFamily = 'FontFamily.Default'; + const lowerFamily = fontFamily.toLowerCase(); + if (lowerFamily.includes('mono') || lowerFamily.includes('courier')) { + kotlinFontFamily = 'FontFamily.Monospace'; + } else if (lowerFamily.includes('serif') && !lowerFamily.includes('sans')) { + kotlinFontFamily = 'FontFamily.Serif'; + } else if (lowerFamily.includes('sans')) { + kotlinFontFamily = 'FontFamily.SansSerif'; + } + + const lineHeight = + typeof typography.lineHeight === 'number' + ? `${(typography.fontSize.value * typography.lineHeight).toFixed(1)}.sp` + : typography.lineHeight + ? dimensionToKotlin(typography.lineHeight, true) + : undefined; + + const letterSpacing = typography.letterSpacing + ? dimensionToKotlin(typography.letterSpacing, true) + : undefined; + + return { + fontSize: dimensionToKotlin(typography.fontSize, true), + fontWeight: fontWeightToKotlin(typography.fontWeight), + fontFamily: kotlinFontFamily, + lineHeight, + letterSpacing, + }; +} + +// ============================================================================= +// Shadow Conversion +// ============================================================================= + +/** + * Convert shadow value to Swift shadow modifier parameters. + */ +export function shadowToSwift(shadow: ShadowValue): { + color: string; + radius: number; + x: number; + y: number; +} { + return { + color: colorToSwift(shadow.color), + radius: dimensionToSwiftValue(shadow.blur), + x: dimensionToSwiftValue(shadow.offsetX), + y: dimensionToSwiftValue(shadow.offsetY), + }; +} + +/** + * Convert shadow value to Kotlin shadow parameters. + */ +export function shadowToKotlin(shadow: ShadowValue): { + color: string; + elevation: string; + offsetX: string; + offsetY: string; +} { + return { + color: colorToKotlin(shadow.color), + elevation: dimensionToKotlin(shadow.blur), + offsetX: dimensionToKotlin(shadow.offsetX), + offsetY: dimensionToKotlin(shadow.offsetY), + }; +} + +// ============================================================================= +// Gradient Conversion +// ============================================================================= + +/** + * Convert gradient stop to Swift format. + */ +function gradientStopToSwift(stop: GradientStop): string { + return `Gradient.Stop(color: ${colorToSwift(stop.color)}, location: ${stop.position.toFixed(3)})`; +} + +/** + * Convert gradient value to Swift LinearGradient/RadialGradient. + */ +export function gradientToSwift(gradient: GradientValue): string { + const stops = gradient.stops.map(gradientStopToSwift).join(',\n '); + + switch (gradient.type) { + case 'linear': { + const angle = gradient.angle ?? 0; + // Convert angle to SwiftUI start/end points + const radians = (angle * Math.PI) / 180; + const startX = 0.5 - Math.cos(radians) * 0.5; + const startY = 0.5 - Math.sin(radians) * 0.5; + const endX = 0.5 + Math.cos(radians) * 0.5; + const endY = 0.5 + Math.sin(radians) * 0.5; + return `LinearGradient( + stops: [ + ${stops} + ], + startPoint: UnitPoint(x: ${startX.toFixed(3)}, y: ${startY.toFixed(3)}), + endPoint: UnitPoint(x: ${endX.toFixed(3)}, y: ${endY.toFixed(3)}) + )`; + } + case 'radial': + return `RadialGradient( + stops: [ + ${stops} + ], + center: .center, + startRadius: 0, + endRadius: 100 + )`; + case 'conic': + return `AngularGradient( + stops: [ + ${stops} + ], + center: .center, + angle: .degrees(${gradient.angle ?? 0}) + )`; + default: + return `LinearGradient(stops: [${stops}], startPoint: .top, endPoint: .bottom)`; + } +} + +/** + * Convert gradient stop to Kotlin format. + */ +function gradientStopToKotlin(stop: GradientStop): string { + return `${stop.position.toFixed(3)}f to ${colorToKotlin(stop.color)}`; +} + +/** + * Convert gradient value to Kotlin Brush. + */ +export function gradientToKotlin(gradient: GradientValue): string { + const stops = gradient.stops.map(gradientStopToKotlin).join(',\n '); + + switch (gradient.type) { + case 'linear': { + const angle = gradient.angle ?? 0; + return `Brush.linearGradient( + colorStops = arrayOf( + ${stops} + ), + start = Offset(0f, 0f), + end = Offset(${Math.cos((angle * Math.PI) / 180).toFixed(3)}f, ${Math.sin((angle * Math.PI) / 180).toFixed(3)}f) + )`; + } + case 'radial': + return `Brush.radialGradient( + colorStops = arrayOf( + ${stops} + ), + center = Offset.Unspecified + )`; + case 'conic': + return `Brush.sweepGradient( + colorStops = arrayOf( + ${stops} + ), + center = Offset.Unspecified + )`; + default: + return `Brush.linearGradient(colorStops = arrayOf(${stops}))`; + } +} + +// ============================================================================= +// Code Generation Utilities +// ============================================================================= + +/** + * Generate Swift file header. + */ +export function swiftFileHeader( + fileName: string, + options: { imports?: string[]; description?: string } = {} +): string { + const imports = options.imports ?? ['SwiftUI']; + const importLines = imports.map((i) => `import ${i}`).join('\n'); + + return `// +// ${fileName} +// Auto-generated design tokens +// +// DO NOT EDIT DIRECTLY - Generated from Figma design tokens +// + +${importLines} +`; +} + +/** + * Generate Kotlin file header. + */ +export function kotlinFileHeader( + packageName: string, + options: { imports?: string[]; description?: string } = {} +): string { + const defaultImports = [ + 'androidx.compose.ui.graphics.Color', + 'androidx.compose.ui.unit.dp', + 'androidx.compose.ui.unit.sp', + ]; + const imports = options.imports ?? defaultImports; + const importLines = imports.map((i) => `import ${i}`).join('\n'); + + return `/** + * Auto-generated design tokens + * + * DO NOT EDIT DIRECTLY - Generated from Figma design tokens + */ + +package ${packageName} + +${importLines} +`; +} + +/** + * Indent content by a number of spaces. + */ +export function indent(content: string, spaces: number = 4): string { + const pad = ' '.repeat(spaces); + return content + .split('\n') + .map((line) => (line.trim() ? `${pad}${line}` : line)) + .join('\n'); +} + +/** + * Escape a Swift string. + */ +export function escapeSwiftString(str: string): string { + return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); +} + +/** + * Escape a Kotlin string. + */ +export function escapeKotlinString(str: string): string { + return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\$/g, '\\$'); +} diff --git a/src/adapters/output/swift-kotlin/index.ts b/src/adapters/output/swift-kotlin/index.ts new file mode 100644 index 0000000..9fbdfac --- /dev/null +++ b/src/adapters/output/swift-kotlin/index.ts @@ -0,0 +1,44 @@ +/** + * Swift/Kotlin Output Adapter + * + * Transforms normalized design tokens into Swift (iOS/SwiftUI) and Kotlin (Android/Compose) + * native constant files. + */ + +export { + SwiftKotlinAdapter, + createSwiftKotlinAdapter, + type SwiftKotlinOutput, + type SwiftKotlinAdapterOptions, +} from './adapter.js'; + +export { + colorToSwift, + colorToUIKit, + colorToSwiftHex, + colorToKotlin, + colorToKotlinArgb, + colorToKotlinHex, + dimensionToSwift, + dimensionToSwiftValue, + dimensionToKotlin, + dimensionToKotlinValue, + tokenNameToSwift, + tokenNameToKotlin, + ensureValidSwiftName, + ensureValidKotlinName, + fontWeightToSwift, + fontWeightToKotlin, + typographyToSwift, + typographyToKotlin, + shadowToSwift, + shadowToKotlin, + gradientToSwift, + gradientToKotlin, + swiftFileHeader, + kotlinFileHeader, + indent, + escapeSwiftString, + escapeKotlinString, + type SwiftDimensionUnit, +} from './converters.js'; diff --git a/src/index.ts b/src/index.ts index 9866c00..850d0f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -106,6 +106,14 @@ export { type AndroidXmlAdapterOptions, } from './adapters/output/android-xml/index.js'; +// Swift/Kotlin Output Adapter +export { + SwiftKotlinAdapter, + createSwiftKotlinAdapter, + type SwiftKotlinOutput, + type SwiftKotlinAdapterOptions, +} from './adapters/output/swift-kotlin/index.js'; + // CSS Input Adapter export { CSSAdapter, @@ -167,6 +175,11 @@ import { type AndroidXmlAdapterOptions, type AndroidXmlOutput, } from './adapters/output/android-xml/index.js'; +import { + createSwiftKotlinAdapter, + type SwiftKotlinAdapterOptions, + type SwiftKotlinOutput, +} from './adapters/output/swift-kotlin/index.js'; import type { ThemeFile } from './schema/tokens.js'; /** @@ -306,3 +319,35 @@ export async function generateAndroidXmlOutput( const adapter = createAndroidXmlAdapter(); return adapter.transform(theme, options); } + +/** + * Quick conversion from Figma data to Swift/Kotlin native constants + * + * @example + * ```typescript + * const output = await figmaToSwiftKotlin({ variablesResponse, fileKey }); + * fs.writeFileSync('DesignTokens.swift', output.swift); + * fs.writeFileSync('DesignTokens.kt', output.kotlin); + * ``` + */ +export async function figmaToSwiftKotlin( + input: FigmaInput, + options?: SwiftKotlinAdapterOptions +): Promise { + const figmaAdapter = createFigmaAdapter(); + const theme = await figmaAdapter.parse(input); + + const outputAdapter = createSwiftKotlinAdapter(); + return outputAdapter.transform(theme, options); +} + +/** + * Generate Swift/Kotlin output from normalized theme + */ +export async function generateSwiftKotlinOutput( + theme: ThemeFile, + options?: SwiftKotlinAdapterOptions +): Promise { + const adapter = createSwiftKotlinAdapter(); + return adapter.transform(theme, options); +} diff --git a/tests/e2e/swift-kotlin-output.spec.ts b/tests/e2e/swift-kotlin-output.spec.ts new file mode 100644 index 0000000..d951006 --- /dev/null +++ b/tests/e2e/swift-kotlin-output.spec.ts @@ -0,0 +1,386 @@ +/** + * E2E Tests: Swift/Kotlin Output Adapter + * + * Tests the Swift/Kotlin output adapter with realistic mock data. + * Validates output for iOS SwiftUI and Android Jetpack Compose. + */ + +import { test, expect } from '@playwright/test'; +import { createFigmaAdapter } from '../../dist/adapters/input/figma/index.js'; +import { createSwiftKotlinAdapter } from '../../dist/adapters/output/swift-kotlin/index.js'; +import { figmaToSwiftKotlin } from '../../dist/index.js'; +import { + mockFigmaVariablesResponse, + mockMCPVariableDefs, +} from './fixtures/figma-variables.js'; + +test.describe('Swift/Kotlin Output Adapter', () => { + test.describe('Basic Generation', () => { + test('generates Swift and Kotlin from Figma data', async () => { + const figmaAdapter = createFigmaAdapter(); + const theme = await figmaAdapter.parse({ + variablesResponse: mockFigmaVariablesResponse, + }); + + const swiftKotlinAdapter = createSwiftKotlinAdapter(); + const output = await swiftKotlinAdapter.transform(theme); + + expect(output.swift).toBeTruthy(); + expect(output.kotlin).toBeTruthy(); + expect(output.swift).toContain('import SwiftUI'); + expect(output.kotlin).toContain('object DesignTokens'); + }); + + test('generates files object with correct paths', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: mockMCPVariableDefs, + }); + + expect(output.files['DesignTokens.swift']).toBeDefined(); + expect(output.files['DesignTokens.kt']).toBeDefined(); + }); + }); + + test.describe('Swift Generation', () => { + test('generates valid Swift file header', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: mockMCPVariableDefs, + }); + + expect(output.swift).toContain('import SwiftUI'); + expect(output.swift).toContain('DO NOT EDIT DIRECTLY'); + expect(output.swift).toContain('Generated from Figma'); + }); + + test('generates Swift colors as Color initializers', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: mockMCPVariableDefs, + }); + + // SwiftUI Color format: Color(red: X.XXX, green: X.XXX, blue: X.XXX, opacity: X.XXX) + expect(output.swift).toContain('Color(red:'); + expect(output.swift).toContain('opacity:'); + }); + + test('generates Swift enum structure', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: mockMCPVariableDefs, + }); + + expect(output.swift).toContain('public enum DesignTokens'); + expect(output.swift).toContain('public enum Colors'); + expect(output.swift).toContain('public static let'); + }); + + test('uses valid Swift identifiers (camelCase)', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: mockMCPVariableDefs, + }); + + // Swift identifiers should be camelCase + // Should not contain hyphens or start with numbers + const staticLetMatches = output.swift.match(/static let (\w+)/g); + expect(staticLetMatches).toBeTruthy(); + for (const match of staticLetMatches || []) { + const name = match.replace('static let ', ''); + // Should not start with uppercase (except for types) + // Should not contain hyphens + expect(name).not.toContain('-'); + expect(name).not.toMatch(/^\d/); + } + }); + + test('generates spacing constants as CGFloat', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: mockMCPVariableDefs, + }); + + expect(output.swift).toContain('CGFloat'); + expect(output.swift).toContain('Spacing'); + }); + + test('includes description comments when enabled', async () => { + const output = await figmaToSwiftKotlin( + { variablesResponse: mockFigmaVariablesResponse }, + { includeComments: true } + ); + + // Should include JSDoc-style comments + expect(output.swift).toContain('///'); + }); + + test('can disable comments', async () => { + const output = await figmaToSwiftKotlin( + { variableDefs: mockMCPVariableDefs }, + { includeComments: false } + ); + + // Should not include description comments (but file header comments are ok) + // Count description comments - should be minimal + const commentLines = output.swift + .split('\n') + .filter((line) => line.trim().startsWith('///') && !line.includes('Design tokens')); + // With comments disabled, there should be very few description comments + expect(commentLines.length).toBeLessThanOrEqual(2); + }); + + test('supports custom type name', async () => { + const output = await figmaToSwiftKotlin( + { variableDefs: mockMCPVariableDefs }, + { swiftTypeName: 'AppColors' } + ); + + expect(output.swift).toContain('public enum AppColors'); + }); + }); + + test.describe('Swift UIKit Extension', () => { + test('does not generate UIKit extension by default', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: mockMCPVariableDefs, + }); + + expect(output.swiftUIKit).toBeUndefined(); + expect(output.files['DesignTokens+UIKit.swift']).toBeUndefined(); + }); + + test('generates UIKit extension when enabled', async () => { + const output = await figmaToSwiftKotlin( + { variableDefs: mockMCPVariableDefs }, + { includeUIKit: true } + ); + + expect(output.swiftUIKit).toBeTruthy(); + expect(output.files['DesignTokens+UIKit.swift']).toBeTruthy(); + expect(output.swiftUIKit).toContain('import UIKit'); + expect(output.swiftUIKit).toContain('UIColor'); + }); + }); + + test.describe('Kotlin Generation', () => { + test('generates valid Kotlin file header', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: mockMCPVariableDefs, + }); + + expect(output.kotlin).toContain('package com.design.tokens'); + expect(output.kotlin).toContain('import androidx.compose.ui.graphics.Color'); + expect(output.kotlin).toContain('DO NOT EDIT DIRECTLY'); + }); + + test('generates Kotlin colors as Color initializers', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: mockMCPVariableDefs, + }); + + // Kotlin Compose Color format: Color(r, g, b, a) + expect(output.kotlin).toMatch(/Color\(\d+\.\d+f/); + }); + + test('generates Kotlin object structure', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: mockMCPVariableDefs, + }); + + expect(output.kotlin).toContain('object DesignTokens'); + expect(output.kotlin).toContain('object Colors'); + expect(output.kotlin).toContain('val '); + }); + + test('uses valid Kotlin identifiers (camelCase)', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: mockMCPVariableDefs, + }); + + // Kotlin identifiers should be camelCase + const valMatches = output.kotlin.match(/val (\w+) =/g); + expect(valMatches).toBeTruthy(); + for (const match of valMatches || []) { + const name = match.replace('val ', '').replace(' =', ''); + expect(name).not.toContain('-'); + expect(name).not.toMatch(/^\d/); + } + }); + + test('generates dimensions with dp/sp units', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: mockMCPVariableDefs, + }); + + expect(output.kotlin).toContain('.dp'); + expect(output.kotlin).toContain('import androidx.compose.ui.unit.dp'); + }); + + test('supports custom package name', async () => { + const output = await figmaToSwiftKotlin( + { variableDefs: mockMCPVariableDefs }, + { kotlinPackage: 'com.myapp.design' } + ); + + expect(output.kotlin).toContain('package com.myapp.design'); + }); + + test('supports custom object name', async () => { + const output = await figmaToSwiftKotlin( + { variableDefs: mockMCPVariableDefs }, + { kotlinObjectName: 'Theme' } + ); + + expect(output.kotlin).toContain('object Theme'); + }); + + test('includes regions for code organization', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: mockMCPVariableDefs, + }); + + expect(output.kotlin).toContain('// region'); + expect(output.kotlin).toContain('// endregion'); + }); + }); + + test.describe('Typography Generation', () => { + test('generates Swift font configuration', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: mockMCPVariableDefs, + }); + + // Should have Typography section + expect(output.swift).toContain('Typography'); + expect(output.swift).toContain('Font.system'); + expect(output.swift).toContain('weight:'); + }); + + test('generates Kotlin TextStyle configuration', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: mockMCPVariableDefs, + }); + + expect(output.kotlin).toContain('Typography'); + expect(output.kotlin).toContain('TextStyle'); + expect(output.kotlin).toContain('fontSize'); + expect(output.kotlin).toContain('fontWeight'); + }); + }); + + test.describe('Shadow Generation', () => { + test('generates Swift shadow style', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: mockMCPVariableDefs, + }); + + // Should have Shadows section if shadow tokens exist + // The mock data has shadow-sm + expect(output.swift).toContain('Shadows'); + expect(output.swift).toContain('ShadowStyle'); + }); + + test('generates Kotlin shadow elevation', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: mockMCPVariableDefs, + }); + + expect(output.kotlin).toContain('Shadows'); + expect(output.kotlin).toContain('Elevation'); + }); + }); + + test.describe('REST API Integration', () => { + test('works with full Figma REST API response', async () => { + const output = await figmaToSwiftKotlin({ + variablesResponse: mockFigmaVariablesResponse, + }); + + expect(output.swift).toBeTruthy(); + expect(output.kotlin).toBeTruthy(); + expect(output.files['DesignTokens.swift']).toBeTruthy(); + expect(output.files['DesignTokens.kt']).toBeTruthy(); + }); + }); + + test.describe('MCP Integration', () => { + test('works with MCP variable definitions', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: mockMCPVariableDefs, + }); + + expect(output.swift).toBeTruthy(); + expect(output.kotlin).toBeTruthy(); + }); + }); + + test.describe('Color Format Options', () => { + test('generates component-based colors by default', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: mockMCPVariableDefs, + }); + + // Default is component-based (Color(red:, green:, blue:, opacity:)) + expect(output.swift).toContain('red:'); + expect(output.kotlin).toMatch(/Color\(\d+\.\d+f/); + }); + + test('supports hex color format for Kotlin', async () => { + const output = await figmaToSwiftKotlin( + { variableDefs: mockMCPVariableDefs }, + { hexColors: true } + ); + + // Hex format: Color(0xAARRGGBB) + expect(output.kotlin).toMatch(/Color\(0x[A-F0-9]{8}\)/); + }); + }); + + test.describe('Converter Functions', () => { + test('colorToSwift produces valid SwiftUI Color', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: { 'Test/Color': '#ff0000' }, + }); + + // Red color should produce Color(red: 1.0, green: 0.0, blue: 0.0, opacity: 1.0) + expect(output.swift).toContain('Color(red:'); + }); + + test('colorToKotlin produces valid Compose Color', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: { 'Test/Color': '#00ff00' }, + }); + + // Green color should have high green component + expect(output.kotlin).toContain('Color('); + }); + + test('tokenNameToSwift produces valid identifiers', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: { + 'Color/Primary-500': '#3880f6', + 'Spacing/Extra Small': '4', + }, + }); + + // Should convert to valid Swift identifiers + // No hyphens in variable names + expect(output.swift).not.toMatch(/static let \w*-\w*/); + // Variable names should be camelCase (no spaces in the name itself) + // Check that names like "primary500" and "extraSmall" exist (camelCase) + expect(output.swift).toMatch(/static let primary500/i); + expect(output.swift).toMatch(/static let extraSmall/i); + }); + + test('tokenNameToKotlin produces valid identifiers', async () => { + const output = await figmaToSwiftKotlin({ + variableDefs: { + 'Color/Primary-500': '#3880f6', + 'Spacing/Extra Small': '4', + }, + }); + + // Should convert to valid Kotlin identifiers + // No hyphens in variable names + expect(output.kotlin).not.toMatch(/val \w*-\w*/); + // Variable names should be camelCase + expect(output.kotlin).toMatch(/val primary500/i); + expect(output.kotlin).toMatch(/val extraSmall/i); + }); + }); +});