slint/tools/figma-inspector/backend/utils/export-variables.ts
2025-06-19 21:38:30 +03:00

1766 lines
73 KiB
TypeScript

// Copyright © SixtyFPS GmbH <info@slint.dev>
// 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<string> = new Set(),
): Promise<string | null> {
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<string, VariableNode>;
}
// 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<string, { value: string; comment?: string }>;
children?: Map<string, PropertyInstance>;
}
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<string, StructDefinition>();
const propertyInstances = new Map<string, PropertyInstance>();
const hasRootModeVariable = variableTree.children.has("mode");
// Local export info tracking
const exportInfo = {
renamedVariables: new Set<string>(),
circularReferences: new Set<string>(),
warnings: new Set<string>(),
};
// 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<string, PropertyInstance>(),
});
// 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<string, PropertyInstance>(),
});
}
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<string, PropertyInstance>(),
});
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<string>();
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<string>;
variables: Map<string, Map<string, VariableModeData>>;
}
// For Figma Plugin - Export function with hierarchical structure
// Export each collection to a separate virtual file
export async function exportFigmaVariablesToSeparateFiles(
exportAsSingleFile = false,
): Promise<Array<{ name: string; content: string }>> {
const exportInfo = {
renamedVariables: new Set<string>(),
circularReferences: new Set<string>(),
warnings: new Set<string>(),
features: new Set<string>(),
collections: new Set<string>(),
};
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<string, CollectionData>();
// 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<string, string>();
// 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<string>(),
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<string, VariableModeData>(),
);
}
// 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<string, Set<string>>();
for (const collection of variableCollections) {
const collectionName = sanitizePropertyName(collection.name);
collectionDependencies.set(collectionName, new Set<string>());
}
// 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<string> },
hasRootModeVariable: boolean, // Add this parameter
) {
// Maps to hold our data model
const schemeStructs = new Map<string, SchemeStruct>();
// 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<string>; 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<string>;
circularReferences: Set<string>;
warnings: Set<string>;
features: Set<string>; // You can add specific features detected if needed
collections: Set<string>;
}): 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;
}