// Copyright © SixtyFPS GmbH // SPDX-License-Identifier: MIT import { rgbToHex } from "./property-parsing"; /** * Helper to get the appropriate Slint type for a Figma variable type * @param figmaType The Figma variable type ('COLOR', 'FLOAT', 'STRING', 'BOOLEAN') * @returns The corresponding Slint type */ function getSlintType(figmaType: string): string { switch (figmaType) { case "COLOR": return "brush"; case "FLOAT": return "length"; case "STRING": return "string"; case "BOOLEAN": return "bool"; default: return "brush"; // Default to brush } } // Helper to format struct/global name for Slint (PascalCase) with sanitization export function formatStructName(name: string): string { let sanitizedName = name.startsWith(".") ? name.substring(1) : name; // If that made it empty, use a default if (!sanitizedName || sanitizedName.trim() === "") { sanitizedName = "property"; } // Replace & with 'and' before other formatting sanitizedName = sanitizedName.replace(/&/g, "and"); // Process commas first, then handle other transformations sanitizedName = sanitizedName .replace(/,\s*/g, "-") // Replace commas (and following spaces) with hyphens .replace(/\+/g, "-") // Replace + with hyphens (add this line) .replace(/\:/g, "-") // Replace : with "" .replace(/\//g, "-") // Replace / with hyphens (fixes enum names) .replace(/—/g, "-") // Replace em dash hyphens .replace(/([a-z])([A-Z])/g, "$1-$2") // Add hyphens between camelCase .replace(/\s+/g, "-") // Convert spaces to hyphens .replace(/--+/g, "-") // Normalize multiple consecutive hyphens to single .toLowerCase(); // Convert to lowercase return sanitizedName; } export function sanitizePropertyName(name: string): string { // Handle names starting with "." - remove the dot let sanitizedName = name.startsWith(".") ? name.substring(1) : name; // Remove leading invalid chars AFTER initial dot check sanitizedName = sanitizedName.replace(/^[_\-\.\(\)&]+/, ""); // If that made it empty, use a default if (!sanitizedName || sanitizedName.trim() === "") { sanitizedName = "property"; // Or handle as error? } // Replace problematic characters BEFORE checking for leading digit // Within individual hierarchy parts: spaces and special chars become hyphens, preserve existing hyphens sanitizedName = sanitizedName .replace(/&/g, "and") // Replace & with 'and' .replace(/[^a-zA-Z0-9\-]/g, "-") // Replace non-alphanumeric chars (except hyphens) with hyphens .replace(/-+/g, "-"); // Collapse multiple hyphens to single hyphen // Remove leading and trailing hyphens sanitizedName = sanitizedName.replace(/^-+/, "").replace(/-+$/, ""); // Check if starts with a digit AFTER other sanitization if (/^\d/.test(sanitizedName)) { return `_${sanitizedName}`; } // Ensure it's not empty again after trailing cleanup if (!sanitizedName || sanitizedName.trim() === "") { return "property"; } return sanitizedName.toLowerCase(); } function sanitizeRowName(rowName: string): string { // Replace & with 'and' and other problematic characters return rowName .replace(/&/g, "and") .replace(/\(/g, "_") // Replace ( with _ .replace(/\)/g, "_"); // Replace ) with _ } // Helper to sanitize mode names for enum variants function sanitizeModeForEnum(name: string): string { // Check if the mode name is only digits if (/^\d+$/.test(name)) { return `mode_${name}`; } // Check if starts with a digit (still invalid for most identifiers) if (/^\d/.test(name)) { return `m_${name}`; } // Replace any characters that are invalid in identifiers return name.replace(/[^a-zA-Z0-9_]/g, "_"); } // Extract hierarchy from variable name (e.g. "colors/primary/base" → ["colors", "primary", "base"]) export function extractHierarchy(name: string): string[] { // First try splitting by slashes (the expected format) if (name.includes("/")) { return name.split("/").map((part) => sanitizePropertyName(part)); } // Default case for simple names return [sanitizePropertyName(name)]; } // Helper function to get original Figma variable data async function getOriginalVariableData(variableId: string) { try { return await figma.variables.getVariableByIdAsync(variableId); } catch (error) { console.warn(`Could not fetch variable ${variableId}:`, error); return null; } } // Helper function to follow a reference chain to find a concrete value async function followChainToConcreteValue( refId: string, modeName: string, visited: Set = new Set(), ): Promise { if (visited.has(refId)) { return null; // Circular reference in the chain } visited.add(refId); // Get the original Figma variable data const originalVariable = await getOriginalVariableData(refId); if (!originalVariable) { return null; } // Look for a concrete value in any mode for (const [, value] of Object.entries(originalVariable.valuesByMode)) { // Check if this value is concrete (not a reference) if ( typeof value === "object" && "type" in value && value.type === "VARIABLE_ALIAS" ) { // This is still a reference, continue following the chain const aliasValue = value as any; if (aliasValue.id) { const result = await followChainToConcreteValue( aliasValue.id, modeName, visited, ); if (result !== null) { return result; } } } else { // Found a concrete value - format it properly based on the variable type const concreteValue: any = value; if ( originalVariable.resolvedType === "COLOR" && typeof concreteValue === "object" && "r" in concreteValue ) { // Format color value return rgbToHex({ r: concreteValue.r, g: concreteValue.g, b: concreteValue.b, a: "a" in concreteValue ? concreteValue.a : 1, }); } if ( originalVariable.resolvedType === "FLOAT" && typeof concreteValue === "number" ) { return `${concreteValue}px`; } if ( originalVariable.resolvedType === "STRING" && typeof concreteValue === "string" ) { return `"${concreteValue}"`; } if ( originalVariable.resolvedType === "BOOLEAN" && typeof concreteValue === "boolean" ) { return concreteValue ? "true" : "false"; } if (typeof concreteValue === "string") { return concreteValue; } } } return null; } function getDefaultValueForType(type: string): string { // Handle both Figma types and Slint types - this should never happen in practice // but we provide a fallback for clear visibility in the generated code switch (type) { // Figma types case "COLOR": return "#FF00FF"; // Magenta for visibility case "FLOAT": return "0px"; case "STRING": return '""'; case "BOOLEAN": return "false"; // Slint types case "brush": return "#FF00FF"; // Magenta for visibility case "length": return "0px"; case "string": return '""'; case "bool": return "false"; default: return "#FF00FF"; // Magenta fallback for visibility } } interface VariableNode { name: string; type?: string; valuesByMode?: Map< string, { value: string; refId?: string; comment?: string } >; children: Map; } // Recursively generate code from the tree structure interface StructField { name: string; type: string; isMultiMode?: boolean; } interface StructDefinition { name: string; fields: StructField[]; path: string[]; } interface PropertyInstance { name: string; type: string; isMultiMode?: boolean; modeData?: Map; children?: Map; } function generateStructsAndInstances( variableTree: VariableNode, _collectionName: string, collectionData: CollectionData, // using strict type interface ): { structs: string; instances: string; } { // Data structures to hold our model const structDefinitions = new Map(); const propertyInstances = new Map(); const hasRootModeVariable = variableTree.children.has("mode"); // Local export info tracking const exportInfo = { renamedVariables: new Set(), circularReferences: new Set(), warnings: new Set(), }; // Build the struct model function buildStructModel(node: VariableNode, path: string[] = []) { // Special handling for root if (node.name === "root") { // Process all root children for (const [childName, childNode] of node.children.entries()) { // Always process child nodes with proper path propagation buildStructModel(childNode, [childName]); // Keep this single-element path for first level } return; } const currentPath = [...path]; const pathKey = currentPath.join("/"); const typeName = currentPath.join("_"); // Only create a struct if this node will have fields const hasValueChildren = Array.from(node.children.values()).some( (child) => child.valuesByMode, ); const hasStructChildren = Array.from(node.children.values()).some( (child) => child.children.size > 0, ); if ( (hasValueChildren || hasStructChildren) && !structDefinitions.has(pathKey) ) { structDefinitions.set(pathKey, { name: `${collectionData.formattedName}_${typeName}`, fields: [], path: [...currentPath], }); } // Process all children recursively, maintaining hierarchical paths for (const [childName, childNode] of node.children.entries()) { // Always process child nodes, appending to the path buildStructModel(childNode, [...currentPath, childName]); // Add field to parent struct const sanitizedChildName = sanitizePropertyName(childName); if (childNode.valuesByMode) { // Value field const slintType = getSlintType(childNode.type || "COLOR"); structDefinitions.get(pathKey)!.fields.push({ name: sanitizedChildName, type: collectionData.modes.size > 1 ? `${collectionData.formattedName}_mode${collectionData.modes.size}_${slintType}` : slintType, isMultiMode: collectionData.modes.size > 1, }); } else if (childNode.children.size > 0) { // Struct reference const childTypeName = [...currentPath, childName].join("_"); structDefinitions.get(pathKey)!.fields.push({ name: sanitizedChildName, type: `${collectionData.formattedName}_${childTypeName}`, }); } } } // Build the instance model function buildInstanceModel(node: VariableNode, path: string[] = []): void { if (node.name === "root") { for (const [childName, childNode] of node.children.entries()) { // Special case for "mode" variable - rename it to avoid collision const sanitizedChildName: string = childName === "mode" && hasRootModeVariable ? "mode-var" : sanitizePropertyName(childName); if (childName === "mode" && hasRootModeVariable) { exportInfo.renamedVariables.add( `"${childName}" → "${sanitizedChildName}" in ${collectionData.formattedName} (to avoid conflict with scheme mode)`, ); // Use formattedName try { figma.notify( "Renamed Figma 'mode' variable to 'mode-var' to avoid conflict", { timeout: 3000 }, ); } catch (e) { // Ignore if not in Figma plugin context } } // Create the property instance with the appropriate name if (childNode.children.size > 0) { propertyInstances.set(sanitizedChildName, { name: sanitizedChildName, type: `${collectionData.formattedName}_${childName}`, // Note: use original name in type children: new Map(), }); // Process children buildInstanceModel(childNode, [sanitizedChildName]); } else if (childNode.valuesByMode) { // Direct value property const slintType: string = getSlintType( childNode.type || "COLOR", ); const instance: PropertyInstance = { name: sanitizedChildName, type: slintType, modeData: new Map< string, { value: string; comment?: string } >(), }; const rowNameKey = childName === "mode" && hasRootModeVariable ? sanitizePropertyName("mode") // Use original "mode" for lookup : sanitizedChildName; // Use normal sanitized name for others const resolvedVariableModesMap = collectionData.variables.get(rowNameKey); if (!resolvedVariableModesMap) { console.error( `Missing resolved variable data for root key: ${rowNameKey}`, ); // Add instance even if empty to avoid breaking hierarchy propertyInstances.set(sanitizedChildName, instance); continue; // Skip to next child } if (collectionData.modes.size > 1) { instance.isMultiMode = true; for (const modeName of collectionData.modes) { const resolvedData = resolvedVariableModesMap.get(modeName); if (resolvedData) { instance.modeData!.set(modeName, { value: resolvedData.value, comment: resolvedData.comment, // <<< Use resolved comment >>> }); } else { console.warn( `Missing resolved data for ${rowNameKey} in mode ${modeName}`, ); instance.modeData!.set(modeName, { value: "#FF00FF", comment: `Missing data for mode ${modeName}`, }); } } } else { // Single mode const singleModeName = [...collectionData.modes][0] || "value"; const resolvedData = resolvedVariableModesMap.get(singleModeName); if (resolvedData) { instance.modeData!.set(singleModeName, { value: resolvedData.value, comment: resolvedData.comment, // <<< Use resolved comment >>> }); } else { console.warn( `Missing resolved data for ${rowNameKey} in single mode ${singleModeName}`, ); instance.modeData!.set(singleModeName, { value: "#FF00FF", comment: `Missing data for mode ${singleModeName}`, }); } } propertyInstances.set(sanitizedChildName, instance); } } return; } // For non-root nodes const pathKey: string = path.join("/"); if (!propertyInstances.has(pathKey)) { propertyInstances.set(pathKey, { name: path[path.length - 1], type: `${collectionData.formattedName}_${path.join("_")}`, children: new Map(), }); } for (const [childName, childNode] of node.children.entries()) { const sanitizedChildName: string = sanitizePropertyName(childName); const childPath: string[] = [...path, sanitizedChildName]; const childPathKey: string = childPath.join("/"); if (childNode.children.size > 0) { // Get parent instance const parentInstance: PropertyInstance | undefined = propertyInstances.get(pathKey); if (!parentInstance || !parentInstance.children) { continue; } // Add child instance to parent parentInstance.children.set(sanitizedChildName, { name: sanitizedChildName, type: `${collectionData.formattedName}_${childPath.join("_")}`, children: new Map(), }); buildInstanceModel(childNode, childPath); } else if (childNode.valuesByMode) { // Get parent instance const parentInstance: PropertyInstance | undefined = propertyInstances.get(pathKey); if (!parentInstance || !parentInstance.children) { continue; } const slintType: string = getSlintType( childNode.type || "COLOR", ); const instance: PropertyInstance = { name: sanitizedChildName, type: slintType, modeData: new Map< string, { value: string; comment?: string } >(), }; const rowNameKey = childPathKey; // Use the pre-calculated childPathKey const resolvedVariableModesMap = collectionData.variables.get(rowNameKey); if (!resolvedVariableModesMap) { console.error( `Missing resolved variable data for nested key: ${rowNameKey}`, ); // Add instance even if empty propertyInstances.set(childPathKey, instance); continue; // Skip to next child } if (collectionData.modes.size > 1) { instance.isMultiMode = true; for (const modeName of collectionData.modes) { const resolvedData = resolvedVariableModesMap.get(modeName); if (resolvedData) { instance.modeData!.set(modeName, { value: resolvedData.value, comment: resolvedData.comment, // <<< Use resolved comment >>> }); } else { console.warn( `Missing resolved data for ${rowNameKey} in mode ${modeName}`, ); instance.modeData!.set(modeName, { value: "#FF00FF", comment: `Missing data for mode ${modeName}`, }); } } } else { // Single mode const singleModeName = [...collectionData.modes][0] || "value"; const resolvedData = resolvedVariableModesMap.get(singleModeName); if (resolvedData) { instance.modeData!.set(singleModeName, { value: resolvedData.value, comment: resolvedData.comment, // <<< Use resolved comment >>> }); } else { console.warn( `Missing resolved data for ${rowNameKey} in single mode ${singleModeName}`, ); instance.modeData!.set(singleModeName, { value: "#FF00FF", comment: `Missing data for mode ${singleModeName}`, }); } } propertyInstances.set(childPathKey, instance); } } } function buildPropertyHierarchy() { // First find all unique top-level paths const topLevelPaths = new Set(); for (const key of propertyInstances.keys()) { if (key.includes("/")) { const parts = key.split("/"); topLevelPaths.add(parts[0]); } } // For each top-level path, create or get the instance for (const path of topLevelPaths) { if (!propertyInstances.has(path)) { propertyInstances.set(path, { name: path, type: `${collectionData.formattedName}_${path}`, children: new Map(), }); } // process all children of this path const childPaths = Array.from(propertyInstances.keys()).filter( (k) => k.startsWith(`${path}/`), ); for (const childPath of childPaths) { const childInstance = propertyInstances.get(childPath); if (!childInstance) { continue; } // Get the parent path const parts = childPath.split("/"); const parentPath = parts.slice(0, -1).join("/"); const childName = parts[parts.length - 1]; // Get the parent instance const parentInstance = propertyInstances.get(parentPath); if (!parentInstance || !parentInstance.children) { continue; } // Add child to parent parentInstance.children.set(childName, childInstance); } } } function generateInstanceCode( instance: PropertyInstance, path: string[] = [], indent = " ", ) { let result = ""; const isRoot = indent === " "; // Special handling for renamed mode variable if (isRoot) { if (instance.name === "mode-var") { result += `${indent}// NOTE: This property was renamed from "mode" to "mode-var" to avoid collision\n`; result += `${indent}// with the scheme mode property of the same name.\n`; } if (instance.children && instance.children.size > 0) { // Struct instance result += `${indent}out property <${instance.type}> ${instance.name}: {\n`; for (const [ childName, childInstance, ] of instance.children.entries()) { result += generateInstanceCode( childInstance, [instance.name, childName], indent + " ", ); } result += `${indent}};\n\n`; } else if (instance.modeData) { const isRoot = indent === " "; const slintType = instance.isMultiMode ? `${collectionData.formattedName}_mode${collectionData.modes.size}_${instance.type}` : instance.type; // Root Value Instance if (instance.isMultiMode) { result += isRoot ? `${indent}out property <${slintType}> ${instance.name}: {\n` : `${indent}${instance.name}: {\n`; for (const [ modeName, modeInfo, ] of instance.modeData.entries()) { if (modeInfo.comment) { // Check if a comment exists for this specific mode result += `${indent} // ${modeInfo.comment}\n`; // Add comment line } result += `${indent} ${modeName}: ${modeInfo.value},\n`; // Add value line } result += isRoot ? `${indent}};\n\n` : `${indent}},\n`; } else { // Single mode root const singleModeName = instance.modeData.keys().next().value || "value"; const modeInfo = instance.modeData.get(singleModeName); if (modeInfo?.comment) { result += `${indent}// ${modeInfo.comment}\n`; } const value = modeInfo?.value || ""; // Handle unresolved refs (replace with default value logic if needed) if (value.startsWith("@ref:")) { result += `${indent}out property <${instance.type}> ${instance.name}: ${ instance.type === "brush" ? "#808080" : instance.type === "length" ? "0px" : instance.type === "string" ? '""' : "false" };\n`; // Removed extra newline } else { result += `${indent}out property <${instance.type}> ${instance.name}: ${value};\n`; // Removed extra newline } } } } else { // Nested property (inside a struct) if (instance.children && instance.children.size > 0) { // Nested Struct result += `${indent}${instance.name}: {\n`; for (const [ childName, childInstance, ] of instance.children.entries()) { // Recurse for children, increasing indent result += generateInstanceCode( childInstance, [...path, childName], indent + " ", ); } result += `${indent}},\n`; // Comma for nested struct field } else if (instance.modeData) { // Nested Value if (instance.isMultiMode) { // Multi-mode nested value result += `${indent}${instance.name}: {\n`; for (const [ modeName, modeInfo, ] of instance.modeData.entries()) { if (modeInfo.comment) { result += `${indent} // ${modeInfo.comment}\n`; } result += `${indent} ${modeName}: ${modeInfo.value},\n`; } result += `${indent}},\n`; // Comma for nested multi-mode field } else { // Single mode nested const singleModeName = instance.modeData.keys().next().value || "value"; const modeInfo = instance.modeData.get(singleModeName); if (modeInfo?.comment) { result += `${indent}// ${modeInfo.comment}\n`; } const value = modeInfo?.value || ""; // Handle unresolved refs (replace with default value logic if needed) if (value.startsWith("@ref:")) { result += `${indent}${instance.name}: ${ instance.type === "brush" ? "#808080" : instance.type === "length" ? "0px" : instance.type === "string" ? '""' : "false" },\n`; } else { result += `${indent}${instance.name}: ${value},\n`; } } } } return result; } // Generate multi-mode structs const multiModeStructs: string[] = []; collectMultiModeStructs(variableTree, collectionData, multiModeStructs); // Build struct model buildStructModel(variableTree); // Build instance model buildInstanceModel(variableTree); buildPropertyHierarchy(); // Generate code from the models let structsCode = multiModeStructs.join(""); // Generate structs in sorted order (deepest first) const sortedPaths = Array.from(structDefinitions.keys()).sort( (a, b) => b.split("/").length - a.split("/").length, ); for (const pathKey of sortedPaths) { const struct = structDefinitions.get(pathKey)!; structsCode += `struct ${struct.name} {\n`; for (const field of struct.fields) { structsCode += ` ${field.name}: ${field.type},\n`; } structsCode += "}\n\n"; } // Generate property instances let instancesCode = ""; // Generate all root level instances for (const [instanceName, instance] of propertyInstances.entries()) { if (!instanceName.includes("/")) { instancesCode += generateInstanceCode(instance); } } return { structs: structsCode, instances: instancesCode, }; } interface VariableModeData { value: string; type: string; refId?: string; comment?: string; } interface CollectionData { name: string; formattedName: string; modes: Set; variables: Map>; } // For Figma Plugin - Export function with hierarchical structure // Export each collection to a separate virtual file export async function exportFigmaVariablesToSeparateFiles( exportAsSingleFile = false, ): Promise> { const exportInfo = { renamedVariables: new Set(), circularReferences: new Set(), warnings: new Set(), features: new Set(), collections: new Set(), }; const generatedFiles: Array<{ name: string; content: string }> = []; // Store intermediate files try { // Get collections asynchronously const variableCollections = await figma.variables.getLocalVariableCollectionsAsync(); // First, initialize the collection structure for ALL collections const collectionStructure = new Map(); // Build a global map of variable paths const variablePathsById = new Map< string, { collection: string; node: VariableNode; path: string[] } >(); // Build a global map of variable names for readable comments const variableNamesById = new Map(); // Initialize structure for all collections first for (const collection of variableCollections) { const collectionName = sanitizePropertyName(collection.name); const formattedCollectionName = formatStructName(collection.name); exportInfo.collections.add(collection.name); // Initialize the collection structure collectionStructure.set(collectionName, { name: collection.name, formattedName: formattedCollectionName, modes: new Set(), variables: new Map(), }); // Add modes to collection collection.modes.forEach((mode) => { const sanitizedMode = sanitizeModeForEnum( sanitizePropertyName(mode.name), ); collectionStructure .get(collectionName)! .modes.add(sanitizedMode); }); } // Pre-populate the global variable names map with ALL variables from ALL collections // This ensures cross-collection variable references show readable names in comments for (const collection of variableCollections) { const batchSize = 10; // Use larger batch size for name collection for (let i = 0; i < collection.variableIds.length; i += batchSize) { const batch = collection.variableIds.slice(i, i + batchSize); const batchPromises = batch.map((id) => figma.variables.getVariableByIdAsync(id), ); const batchResults = await Promise.all(batchPromises); for (const variable of batchResults) { if (variable && variable.name) { variableNamesById.set(variable.id, variable.name); } } } } // process the variables for each collection for (const collection of variableCollections) { const collectionName = sanitizePropertyName(collection.name); // Process variables in batches const batchSize = 5; for (let i = 0; i < collection.variableIds.length; i += batchSize) { const batch = collection.variableIds.slice(i, i + batchSize); const batchPromises = batch.map((id) => figma.variables.getVariableByIdAsync(id), ); const batchResults = await Promise.all(batchPromises); for (const variable of batchResults) { if (!variable) { continue; } if ( !variable.valuesByMode || Object.keys(variable.valuesByMode).length === 0 ) { continue; } // Use extractHierarchy to break up variable names const nameParts = extractHierarchy(variable.name); // For flat structure (existing code) const propertyName = nameParts.length > 0 ? nameParts[nameParts.length - 1] : sanitizePropertyName(variable.name); const path = nameParts.length > 1 ? nameParts.slice(0, -1).join("/") : ""; const rowName = path ? `${path}/${propertyName}` : propertyName; const sanitizedRowName = sanitizeRowName(rowName); // Initialize row in variables map if ( !collectionStructure .get(collectionName)! .variables.has(sanitizedRowName) ) { collectionStructure .get(collectionName)! .variables.set( sanitizedRowName, new Map(), ); } // Process values for each mode // First, create a reverse lookup map for collection modes by both modeId and name const modeIdToInfo = new Map< string, { modeId: string; name: string } >(); const modeNameToInfo = new Map< string, { modeId: string; name: string } >(); for (const mode of collection.modes) { modeIdToInfo.set(mode.modeId, mode); // Also index by sanitized name for fallback matching const sanitizedName = sanitizeModeForEnum( sanitizePropertyName(mode.name), ); modeNameToInfo.set(sanitizedName, mode); } // Process all modes that the collection expects, not just what's in valuesByMode for (const collectionMode of collection.modes) { const modeName = sanitizeModeForEnum( sanitizePropertyName(collectionMode.name), ); let value: any = null; let foundModeId: string | null = null; // Strategy 1: Try exact modeId match if (variable.valuesByMode[collectionMode.modeId]) { value = variable.valuesByMode[collectionMode.modeId]; foundModeId = collectionMode.modeId; } else { // Strategy 2: Try to find by mode name matching // Look for a variable mode that when sanitized matches the collection mode name for (const [varModeId, varValue] of Object.entries( variable.valuesByMode, )) { // Extract the potential mode name from the variable's modeId // Common patterns: "mode_light" -> "light", "light_mode" -> "light", etc. const varModeName = varModeId .replace(/^(mode_|_mode$)/, "") // Remove mode_ prefix or _mode suffix .replace(/_/g, " ") // Replace underscores with spaces .toLowerCase(); const sanitizedVarModeName = sanitizeModeForEnum( sanitizePropertyName(varModeName), ); if ( sanitizedVarModeName === modeName.toLowerCase() || varModeName === collectionMode.name.toLowerCase() ) { value = varValue; foundModeId = varModeId; console.log( `Found mode name match: ${varModeId} -> ${modeName}`, ); break; } } // Strategy 3: Enhanced fallback - try to distribute different values to different collection modes if (value === null) { const availableValues = Object.entries( variable.valuesByMode, ); if (availableValues.length > 0) { // Try to map collection modes to different variable modes when possible // Get the index of this collection mode const collectionModeIndex = collection.modes.findIndex( (mode) => mode.modeId === collectionMode.modeId, ); // Use different available values for different collection modes const valueIndex = Math.min( collectionModeIndex, availableValues.length - 1, ); [foundModeId, value] = availableValues[valueIndex]; console.warn( `Mode mismatch for variable ${variable.name}: using fallback value from mode ${foundModeId} (index ${valueIndex}) for expected mode ${modeName}`, ); } } } // Skip if no value found at all if (value === null) { console.warn( `No value found for variable ${variable.name} in mode ${modeName}`, ); continue; } // Format value and resolve all references immediately let formattedValue = ""; let comment: string | undefined; // Process different variable types (COLOR, FLOAT, STRING, BOOLEAN) if (variable.resolvedType === "COLOR") { if ( typeof value === "object" && value && "r" in value ) { formattedValue = rgbToHex({ r: value.r, g: value.g, b: value.b, a: "a" in value ? value.a : 1, }); } else if ( typeof value === "object" && value && "type" in value && value.type === "VARIABLE_ALIAS" ) { // Resolve reference to concrete value immediately const resolvedValue = await followChainToConcreteValue( value.id, modeName, ); if (resolvedValue) { formattedValue = resolvedValue; const referenceName = variableNamesById.get(value.id) || value.id; comment = `Resolved from reference ${referenceName}`; } else { formattedValue = getDefaultValueForType("brush"); const referenceName = variableNamesById.get(value.id) || value.id; comment = `Failed to resolve reference ${referenceName}, using default`; exportInfo.warnings.add( `Failed to resolve COLOR reference ${value.id} for ${variable.name}.${modeName}`, ); } } } else if (variable.resolvedType === "FLOAT") { if (typeof value === "number") { formattedValue = `${value}px`; } else if ( typeof value === "object" && value && "type" in value && value.type === "VARIABLE_ALIAS" ) { // Resolve reference to concrete value immediately const resolvedValue = await followChainToConcreteValue( value.id, modeName, ); if (resolvedValue) { formattedValue = resolvedValue; const referenceName = variableNamesById.get(value.id) || value.id; comment = `Resolved from reference ${referenceName}`; } else { formattedValue = getDefaultValueForType("length"); const referenceName = variableNamesById.get(value.id) || value.id; comment = `Failed to resolve reference ${referenceName}, using default`; exportInfo.warnings.add( `Failed to resolve FLOAT reference ${value.id} for ${variable.name}.${modeName}`, ); } } else { console.warn( `Unexpected FLOAT value type: ${typeof value} for ${variable.name}`, ); formattedValue = getDefaultValueForType("length"); } } else if (variable.resolvedType === "STRING") { if (typeof value === "string") { formattedValue = `"${value}"`; } else if ( typeof value === "object" && value && "type" in value && value.type === "VARIABLE_ALIAS" ) { // Resolve reference to concrete value immediately const resolvedValue = await followChainToConcreteValue( value.id, modeName, ); if (resolvedValue) { formattedValue = resolvedValue; const referenceName = variableNamesById.get(value.id) || value.id; comment = `Resolved from reference ${referenceName}`; } else { formattedValue = getDefaultValueForType("string"); const referenceName = variableNamesById.get(value.id) || value.id; comment = `Failed to resolve reference ${referenceName}, using default`; exportInfo.warnings.add( `Failed to resolve STRING reference ${value.id} for ${variable.name}.${modeName}`, ); } } else { console.warn( `Unexpected STRING value type: ${typeof value} for ${variable.name}`, ); formattedValue = getDefaultValueForType("string"); } } else if (variable.resolvedType === "BOOLEAN") { if (typeof value === "boolean") { formattedValue = value ? "true" : "false"; } else if ( typeof value === "object" && value && "type" in value && value.type === "VARIABLE_ALIAS" ) { // Resolve reference to concrete value immediately const resolvedValue = await followChainToConcreteValue( value.id, modeName, ); if (resolvedValue) { formattedValue = resolvedValue; const referenceName = variableNamesById.get(value.id) || value.id; comment = `Resolved from reference ${referenceName}`; } else { formattedValue = getDefaultValueForType("bool"); const referenceName = variableNamesById.get(value.id) || value.id; comment = `Failed to resolve reference ${referenceName}, using default`; exportInfo.warnings.add( `Failed to resolve BOOLEAN reference ${value.id} for ${variable.name}.${modeName}`, ); } } else { console.warn( `Unexpected BOOLEAN value type: ${typeof value} for ${variable.name}`, ); formattedValue = getDefaultValueForType("bool"); } } collectionStructure .get(collectionName)! .variables.get(sanitizedRowName)! .set(modeName, { value: formattedValue, type: variable.resolvedType, // No refId - all references are resolved to concrete values comment: comment, }); } // Store the path for each variable ID variablePathsById.set(variable.id, { collection: collectionName, node: { name: propertyName, type: variable.resolvedType, valuesByMode: new Map(), children: new Map(), }, path: nameParts, }); } // Force GC between batches await new Promise((resolve) => setTimeout(resolve, 0)); } } // All references have been resolved to concrete values during variable storage // No need for post-processing since there are no more refId references // Since all references are now resolved to concrete values, there are no cross-collection dependencies const collectionDependencies = new Map>(); for (const collection of variableCollections) { const collectionName = sanitizePropertyName(collection.name); collectionDependencies.set(collectionName, new Set()); } // No dependency cycles possible since all variables now have concrete values const finalExportAsSingleFile = exportAsSingleFile; // Generate content for each collection for (const [ _collectionName, collectionData, ] of collectionStructure.entries()) { // Skip collections with no variables if (collectionData.variables.size === 0) { exportInfo.warnings.add( `Skipped empty collection: ${collectionData.name}`, ); continue; } // Build the variable tree for this collection const variableTree: VariableNode = { name: "root", children: new Map(), }; for (const [varName, modes] of collectionData.variables.entries()) { const parts = extractHierarchy(varName); let currentNode = variableTree; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; if (!currentNode.children.has(part)) { currentNode.children.set(part, { name: part, children: new Map(), }); } currentNode = currentNode.children.get(part)!; } const propertyName = sanitizePropertyName( parts[parts.length - 1], ); if (!currentNode.children.has(propertyName)) { const valuesByMode = new Map< string, { value: string; refId?: string; comment?: string } >(); const firstModeValue = modes.values().next().value; const type = firstModeValue?.type || "COLOR"; for (const [modeName, valueData] of modes.entries()) { valuesByMode.set(modeName, { value: valueData.value, refId: valueData.refId, comment: valueData.comment, }); } currentNode.children.set(propertyName, { name: propertyName, type: type, valuesByMode: valuesByMode, children: new Map(), }); } } // Generate structs and instances code from the tree const { structs, instances } = generateStructsAndInstances( variableTree, collectionData.formattedName, collectionData, // Pass exportInfo if needed ); // Generate scheme-related code (only if multi-mode) let modeEnum = ""; let schemeStruct = ""; let schemeModeStruct = ""; let schemeInstance = ""; let currentSchemeInstance = ""; if (collectionData.modes.size > 1) { // Generate Enum modeEnum += `export enum ${collectionData.formattedName}Mode {\n`; for (const mode of collectionData.modes) { modeEnum += ` ${mode},\n`; } modeEnum += "}\n\n"; // Generate Scheme Structs/Instances const hasRootModeVariable = variableTree.children.has("mode"); const schemeResult = generateSchemeStructs( variableTree, collectionData, hasRootModeVariable, ); schemeStruct = schemeResult.schemeStruct; schemeModeStruct = schemeResult.schemeModeStruct; schemeInstance = schemeResult.schemeInstance; currentSchemeInstance = schemeResult.currentSchemeInstance; } let content = `// Generated Slint file for ${collectionData.formattedName}\n\n`; // No imports needed since all references are resolved to concrete values // Add Enum (if generated) content += modeEnum; // Add Structs (multi-mode base structs and specific structs generated by generateStructsAndInstances) content += structs; // Add Scheme structs (if generated) content += schemeStruct; content += schemeModeStruct; // Add the main global block containing instances and scheme properties content += `export global ${collectionData.formattedName} {\n`; content += instances; // Add the generated instance code lines content += schemeInstance; // Add scheme instance code (if generated) content += currentSchemeInstance; // Add current instance code (if generated) content += "}\n"; // Close global block (removed extra \n\n) // Store the fully assembled content for this collection generatedFiles.push({ name: `${collectionData.formattedName}.slint`, content: content.trim() + "\n", // Trim whitespace and add single trailing newline }); } // Post-process generated files (e.g., replace unresolved refs) for (const file of generatedFiles) { // Check if there are any unresolved references left if (file.content.includes("@ref:")) { exportInfo.warnings.add( `Found unresolved references in ${file.name}`, ); // Replace unresolved references with appropriate defaults based on context file.content = file.content.replace( /(@ref:VariableID:[0-9:]+)/g, (_match, reference) => { exportInfo.warnings.add( ` Replacing unresolved reference: ${reference}`, ); // Look at surrounding context to determine appropriate replacement if ( file.content.includes("brush,\n") && file.content.includes(reference) ) { return "#808080"; // Default color } if ( file.content.includes("length,\n") && file.content.includes(reference) ) { return "0px"; // Default length } if ( file.content.includes("string,\n") && file.content.includes(reference) ) { return '""'; // Default string } if ( file.content.includes("bool,\n") && file.content.includes(reference) ) { return "false"; // Default boolean } return "#808080"; // Default fallback }, ); } } // Determine final output structure (single vs multiple files) let finalOutputFiles: Array<{ name: string; content: string }> = []; if (finalExportAsSingleFile) { // Use the determined flag let combinedContent = "// Combined Slint Design Tokens\n// Generated on " + new Date().toISOString().split("T")[0] + "\n\n"; for (const file of generatedFiles) { combinedContent += `// --- Content from ${file.name} ---\n\n`; combinedContent += file.content; combinedContent += `\n\n// --- End Content from ${file.name} ---\n\n`; } finalOutputFiles.push({ name: "design-tokens.slint", content: combinedContent.trim(), }); } else { // Use individual files finalOutputFiles = generatedFiles; } // Add README const readmeContent = generateReadmeContent(exportInfo); finalOutputFiles.push({ name: "README.md", content: readmeContent, }); return finalOutputFiles; // Return the final array } catch (error) { console.error("Error in exportFigmaVariablesToSeparateFiles:", error); return [ { name: "error.slint", content: `// Error generating variables: ${error}`, }, ]; } } // Define proper type interfaces for our structure interface SchemeField { name: string; type: string; } interface SchemeStruct { name: string; fields: SchemeField[]; path: string[]; } // Main function for generating scheme structs function generateSchemeStructs( variableTree: VariableNode, collectionData: { name: string; formattedName: string; modes: Set }, hasRootModeVariable: boolean, // Add this parameter ) { // Maps to hold our data model const schemeStructs = new Map(); // Build structure recursively using a proper object model function buildSchemeModel(node: VariableNode, path: string[] = []) { if (path.length > 0) { const pathKey = path.join("/"); if (!schemeStructs.has(pathKey)) { schemeStructs.set(pathKey, { name: `${collectionData.formattedName}-${path.join("-")}-Scheme`, fields: [], path: [...path], }); } // Add fields to the struct for (const [childName, childNode] of node.children.entries()) { if (childNode.valuesByMode) { schemeStructs.get(pathKey)!.fields.push({ name: childName, type: getSlintType(childNode.type || "COLOR"), }); } else if (childNode.children.size > 0) { const childPath = [...path, childName]; schemeStructs.get(pathKey)!.fields.push({ name: childName, type: `${collectionData.formattedName}-${childPath.join("-")}-Scheme`, }); } } } // Recursively process child nodes for (const [childName, childNode] of node.children.entries()) { if (childNode.children.size > 0) { buildSchemeModel(childNode, [...path, childName]); } } } // Build the model buildSchemeModel(variableTree); // Now generate code from the model (separate concern) let schemeStruct = ""; // Generate structs in sorted order (deepest first) const sortedPaths = Array.from(schemeStructs.keys()).sort( (a, b) => b.split("/").length - a.split("/").length, ); for (const pathKey of sortedPaths) { const struct = schemeStructs.get(pathKey)!; schemeStruct += `struct ${struct.name} {\n`; for (const field of struct.fields) { schemeStruct += ` ${field.name}: ${field.type},\n`; } schemeStruct += "}\n\n"; } // Main scheme struct is special - it gets top-level fields const schemeName = `${collectionData.formattedName}-Scheme`; // Change this variable name to avoid redeclaration let mainSchemeStruct = `struct ${schemeName} {\n`; // Add all direct children of root for (const [childName, childNode] of variableTree.children.entries()) { if (childNode.valuesByMode) { // Direct property mainSchemeStruct += ` ${childName}: ${getSlintType(childNode.type || "COLOR")},\n`; } else if (childNode.children.size > 0) { // Reference to child scheme mainSchemeStruct += ` ${childName}: ${collectionData.formattedName}-${childName}-Scheme,\n`; } } mainSchemeStruct += "}\n\n"; // Generate the mode struct const schemeModeName = `${collectionData.formattedName}-Scheme-Mode`; let schemeModeStruct = `struct ${schemeModeName} {\n`; for (const mode of collectionData.modes) { schemeModeStruct += ` ${mode}: ${schemeName},\n`; } schemeModeStruct += "}\n\n"; // Generate the instance initialization let schemeInstance = ` out property <${schemeModeName}> mode: {\n`; for (const mode of collectionData.modes) { schemeInstance += ` ${mode}: {\n`; // Function to add hierarchical values function addHierarchicalValues( node: VariableNode = variableTree, path: string[] = [], currentIndent = " ", ) { for (const [childName, childNode] of node.children.entries()) { const currentPath = [...path, childName]; if (childNode.children.size > 0) { // This is a struct node schemeInstance += `${currentIndent}${childName}: {\n`; // Recursively add its children addHierarchicalValues( childNode, currentPath, currentIndent + " ", ); schemeInstance += `${currentIndent}},\n`; } else if (childNode.valuesByMode) { // This is a leaf value // Check if this is the renamed "mode" variable at root level const propertyPath = currentPath.join("."); const referencePath = hasRootModeVariable && propertyPath === "mode" ? "mode-var" : propertyPath; schemeInstance += `${currentIndent}${childName}: ${collectionData.formattedName}.${referencePath}.${mode},\n`; } } } // Build the mode instance addHierarchicalValues(); schemeInstance += " },\n"; } // Close the mode instance schemeInstance += " };\n"; // Generate the current scheme property with current-mode toggle let currentSchemeInstance = ` in-out property <${collectionData.formattedName}Mode> current-mode: ${[...collectionData.modes][0]};\n`; // Add the current property that dynamically selects based on the enum currentSchemeInstance += ` out property <${schemeName}> current: `; // for mode specific disentanglement const modePropertyName = hasRootModeVariable ? "mode-var" : "mode"; const modeArray = [...collectionData.modes]; if (modeArray.length === 0) { // No modes - empty object currentSchemeInstance += "{};\n\n"; } else if (modeArray.length === 1) { // One mode - direct reference currentSchemeInstance += `root.${modePropertyName}.${modeArray[0]};\n\n`; } else { // Multiple modes - build a ternary chain let expression = ""; // Build the ternary chain from the first mode to the second-to-last for (let i = 0; i < modeArray.length - 1; i++) { if (i > 0) { expression += "\n "; } expression += `current-mode == ${collectionData.formattedName}Mode.${modeArray[i]} ? root.mode.${modeArray[i]} : `; } // Add the final fallback (last mode) expression += `root.mode.${modeArray[modeArray.length - 1]}`; // Add the expression with proper indentation currentSchemeInstance += `\n ${expression};\n\n`; } return { // Combine both scheme structs in the return value schemeStruct: schemeStruct + mainSchemeStruct, schemeModeStruct: schemeModeStruct, schemeInstance: schemeInstance, currentSchemeInstance: currentSchemeInstance, }; } function collectMultiModeStructs( node: VariableNode, collectionData: { modes: Set; formattedName: string }, structDefinitions: string[], ) { if (collectionData.modes.size <= 1) { return; } // Define all Slint types we want to support const allSlintTypes = ["brush", "length", "string", "bool"]; // Generate a struct for each type regardless of whether it's used for (const slintType of allSlintTypes) { const structName = `${collectionData.formattedName}_mode${collectionData.modes.size}_${slintType}`; let structDef = `struct ${structName} {\n`; for (const mode of collectionData.modes) { structDef += ` ${mode}: ${slintType},\n`; } structDef += "}\n\n"; structDefinitions.push(structDef); } // Still scan the tree for any other types we might have missed (for future proofing) function findUniqueTypeConfigs(node: VariableNode) { for (const [_childName, childNode] of node.children.entries()) { if (childNode.valuesByMode && childNode.valuesByMode.size > 0) { const slintType = getSlintType(childNode.type || "COLOR"); // Skip if we already added this type if (!allSlintTypes.includes(slintType)) { // Add a struct for this additional type const structName = `${collectionData.formattedName}_mode${collectionData.modes.size}_${slintType}`; let structDef = `struct ${structName} {\n`; for (const mode of collectionData.modes) { structDef += ` ${mode}: ${slintType},\n`; } structDef += "}\n\n"; structDefinitions.push(structDef); } } else if (childNode.children.size > 0) { findUniqueTypeConfigs(childNode); } } } // Look for any additional types findUniqueTypeConfigs(node); } function generateReadmeContent(exportInfo: { renamedVariables: Set; circularReferences: Set; warnings: Set; features: Set; // You can add specific features detected if needed collections: Set; }): string { let content = "# Figma Design Tokens Export\n\n"; content += `Generated on ${new Date().toISOString().split("T")[0]}\n\n`; content += "Instructions for use: \n"; content += "This library attempts to export a working set of slint design tokens. They are constructed so that the variables can be called using dot notation. \n"; content += "If attempting to use colors that change using modes, procure to use the .current. after the initial global. Then changing the current-mode variable (using the appropriate global's enum) will allow to switch the mode for every variable using .current. \n\n"; if (exportInfo.collections.size > 0) { content += "## Exported Collections\n\n"; exportInfo.collections.forEach((collection) => { content += `- ${collection}\n`; }); content += "\n"; } if (exportInfo.renamedVariables.size > 0) { content += "## Renamed Variables\n\n"; content += "The following variables were renamed to avoid conflicts:\n\n"; exportInfo.renamedVariables.forEach((variable) => { content += `- ${variable}\n`; }); content += "\n"; } if (exportInfo.circularReferences.size > 0) { content += "## Circular References\n\n"; content += "The following circular references were detected and resolved with defaults:\n\n"; exportInfo.circularReferences.forEach((ref) => { content += `- ${ref}\n`; }); content += "\n"; } if (exportInfo.warnings.size > 0) { content += "## Warnings\n\n"; exportInfo.warnings.forEach((warning) => { content += `- ${warning}\n`; }); } return content; }