Skip to content

Commit

Permalink
Adaptive UI: Improved Figma CLI (#211)
Browse files Browse the repository at this point in the history
- Improved support for generating conditions
- Improved support for editing anatomy file
- Added individual components to the list (not sets)
  • Loading branch information
bheston authored Jun 28, 2024
1 parent 892cbd2 commit 50c6ba5
Show file tree
Hide file tree
Showing 14 changed files with 171 additions and 126 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Improved Figma CLI",
"packageName": "@adaptive-web/adaptive-ui-designer-figma",
"email": "47367562+bheston@users.noreply.github.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "Improved Figma CLI",
"packageName": "@adaptive-web/adaptive-ui",
"email": "47367562+bheston@users.noreply.github.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ export class CodeGen {

#genStyleRuleCode(componentName: string, styleRule: StyleRule): string {
let targetOut = "";
const contextCondition = styleRule.contextCondition ? ` contextCondition: ${componentName}Anatomy.conditions.${camelCase(styleRule.contextCondition)},\n` : "";
// HACK: The model has changed but this class never fully handled conditions yet anyway. Revisit this when the model changes are complete.
const contextCondition = ""; // styleRule.contextCondition ? ` contextCondition: ${componentName}Anatomy.conditions.${camelCase(styleRule.contextCondition)},\n` : "";
const part = styleRule.part ? ` part: ${componentName}Anatomy.parts.${camelCase(styleRule.part)},\n` : "";
if (contextCondition || part) {
targetOut = ` target: {\n${part}${contextCondition} },\n`;
Expand Down
5 changes: 2 additions & 3 deletions packages/adaptive-ui-designer-figma-plugin/src/figma/node.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Shadow, StyleProperty } from "@adaptive-web/adaptive-ui";
import { type Color, modeLrgb, modeRgb, parse, type Rgb, useMode, wcagLuminance } from "culori/fn";
import { Shadow, StyleProperty } from "@adaptive-web/adaptive-ui";
import { Controller, STYLE_REMOVE } from "@adaptive-web/adaptive-ui-designer-core";
import { AppliedStyleModules, AppliedStyleValues, PluginNodeData } from "@adaptive-web/adaptive-ui-designer-core";
import { focusIndicatorNodeName, PluginNode, State, StatesState } from "@adaptive-web/adaptive-ui-designer-core";
import { FIGMA_SHARED_DATA_NAMESPACE } from "@adaptive-web/adaptive-ui-designer-figma";
import { colorToRgba, variantBooleanHelper } from "./utility.js";

const rgb = useMode(modeRgb);
Expand Down Expand Up @@ -105,8 +106,6 @@ function canHaveChildren(node: BaseNode): node is
].some((test: (node: BaseNode) => boolean) => test(node));
}

const FIGMA_SHARED_DATA_NAMESPACE: string = "adaptive_ui";

export class FigmaPluginNode extends PluginNode {
public id: string;
public type: string;
Expand Down
24 changes: 14 additions & 10 deletions packages/adaptive-ui-designer-figma/src/cli/anatomy.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
/* eslint @typescript-eslint/naming-convention: off */
import fs from 'fs/promises';
import path from 'path';
import { SerializableAnatomy } from '@adaptive-web/adaptive-ui';
import type * as FigmaRestAPI from '@figma/rest-api-spec';
import { kebabCase } from 'change-case';
import { parseNode } from '../lib/node-parser.js';
import { Anatomy, type SerializableAnatomy, } from '../lib/anatomy.js';
import { Anatomy } from '../lib/anatomy.js';
import { ILibraryConfig } from './library-config.js';
import { ILogger } from './logger.js';

Expand All @@ -15,11 +16,9 @@ export interface IAnatomyConfiguration {

export class AnatomyConfiguration implements IAnatomyConfiguration {
public readonly name: string;
#anatomy: SerializableAnatomy;

private constructor(anatomy: SerializableAnatomy, public readonly path: string) {
this.name = anatomy.name;
this.#anatomy = anatomy;
}

public static async create(
Expand All @@ -35,13 +34,18 @@ export class AnatomyConfiguration implements IAnatomyConfiguration {
await fs.stat(configurationPath);
logger.neutral(`Anatomy file for ${name} already exists! Using existing anatomy.`);
anatomy = JSON.parse((await fs.readFile(configurationPath)).toString()) as unknown as SerializableAnatomy;
} catch {
logger.success(`Writing anatomy file for ${name}.`);
const nodeAnatomy = Anatomy.fromPluginUINodeData(parseNode(node))
const nodeAnatomyFileData = JSON.stringify(nodeAnatomy, null, 2);
anatomy = JSON.parse(nodeAnatomyFileData);
await fs.mkdir(path.parse(configurationPath).dir, { recursive: true }); // ensure dir exists or fs.writeFile will throw
await fs.writeFile(configurationPath, nodeAnatomyFileData);
} catch (e) {
if ((e as Error).message.startsWith("ENOENT")) {
logger.success(`Writing anatomy file for ${name}.`);
const nodeAnatomy = Anatomy.fromPluginUINodeData(parseNode(node))
const nodeAnatomyFileData = JSON.stringify(nodeAnatomy, null, 2);
anatomy = JSON.parse(nodeAnatomyFileData);
await fs.mkdir(path.parse(configurationPath).dir, { recursive: true }); // ensure dir exists or fs.writeFile will throw
await fs.writeFile(configurationPath, nodeAnatomyFileData);
} else {
logger.warn(`Anatomy file error: ${(e as Error).message}.`);
throw e;
}
}

return new AnatomyConfiguration(anatomy, configurationPath);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ export interface FigmaRESTClient {
*/
getFileComponents(
pathParams: FigmaRestAPI.GetFileComponentsPathParams,
queryParams: FigmaRestAPI.GetFilePathParams
queryParams?: FigmaRestAPI.GetFilePathParams
): Promise<FigmaRestAPI.GetFileComponentsResponse>;

/**
Expand Down
23 changes: 21 additions & 2 deletions packages/adaptive-ui-designer-figma/src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,14 @@ async function main({ library }: ProgramOptions) {
});

logger.neutral('Requesting Figma Library.');
const libraryComponentsResponse = await client.getFileComponentSets(libraryConfig.file);
const libraryComponentSetsResponse = await client.getFileComponentSets(libraryConfig.file);

if (libraryComponentSetsResponse.error || libraryComponentSetsResponse.status !== 200) {
logger.fail(`Accessing Figma library failed with status code ${libraryComponentSetsResponse.status}`);
process.exit(1);
}

const libraryComponentsResponse = await client.getFileComponents(libraryConfig.file);

if (libraryComponentsResponse.error || libraryComponentsResponse.status !== 200) {
logger.fail(`Accessing Figma library failed with status code ${libraryComponentsResponse.status}`);
Expand All @@ -56,7 +63,19 @@ async function main({ library }: ProgramOptions) {

logger.success('Your library was successfully retrieved!');

const { component_sets: allComponents } = libraryComponentsResponse.meta;
const { component_sets: libraryComponentSets } = libraryComponentSetsResponse.meta;
const { components: libraryComponents } = libraryComponentsResponse.meta;

// The file components endpoint returns _all_ components including within a set, filter those out.
const uniqueComponents = libraryComponents.filter(component =>
// Also filter out components which aren't in a container frame (assume they are helper/utility for now)
component.containing_frame !== undefined &&
libraryComponentSets.find(componentSet =>
componentSet.containing_frame?.nodeId === component.containing_frame?.nodeId ||
componentSet.node_id === component.containing_frame?.nodeId
) === undefined
);
const allComponents = libraryComponentSets.concat(uniqueComponents);

const componentNames = allComponents.map((value) => value.name).sort(alphabetize);
const pickComponentsRequest = {
Expand Down
111 changes: 45 additions & 66 deletions packages/adaptive-ui-designer-figma/src/lib/anatomy.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
/* eslint-disable max-len */
import { camelCase, kebabCase } from "change-case";
import { kebabCase } from "change-case";
import {
Interactivity,
InteractivityDefinition,
SerializableAnatomy,
SerializableBooleanCondition,
SerializableStringCondition,
SerializableStyleRule,
SerializableToken,
} from "@adaptive-web/adaptive-ui";
import { AdditionalDataKeys, type PluginUINodeData } from "@adaptive-web/adaptive-ui-designer-core";

Expand All @@ -15,33 +20,6 @@ function makeClassName(value: string) {

// TODO: All of these type definitions will be merged into the core AUI package, including simple interface and serialization support.

export type SerializableBooleanCondition = string;

export type SerializableStringCondition = Record<string, string>;

export type SerializableCondition = SerializableBooleanCondition | SerializableStringCondition;

export interface SerializableToken {
target: string;
tokenID: string;
}

export interface SerializableStyleRule {
contextCondition?: string;
part?: string;
styles?: string[];
tokens?: SerializableToken[];
}

export interface SerializableAnatomy {
name: string;
context: string;
interactivity?: InteractivityDefinition;
conditions: Record<string, SerializableCondition>;
parts: Record<string, string>;
styleRules: SerializableStyleRule[];
}

export abstract class Condition {
constructor(
public readonly name: string,
Expand Down Expand Up @@ -91,17 +69,16 @@ export class Token {
}

export class StyleRule {
contextCondition?: string;
part: string = "";
contextCondition?: Record<string, string | boolean>;
part?: string;
styles: Set<string> = new Set();
tokens: Set<Token> = new Set();

toJSON(): SerializableStyleRule {
const contextCondition = typeof this.contextCondition === "string" ? "." + kebabCase(this.contextCondition) : undefined;
return {
contextCondition,
part: this.part || "",
styles: Array.from(this.styles),
contextCondition: this.contextCondition,
part: this.part,
styles: this.styles.size === 0 ? undefined : Array.from(this.styles),
tokens: this.tokens.size === 0 ? undefined : Array.from(this.tokens).map(token => token.toJSON()),
};
}
Expand All @@ -119,12 +96,12 @@ export class Anatomy implements Anatomy {
const conditions = Array.from(this.conditions.entries()).reduce((prev, next) => {
prev[next[0]] = next[1].toJSON()
return prev
}, {} as SerializableAnatomy['conditions'])
}, {} as SerializableAnatomy["conditions"])

const parts = Array.from(this.parts.entries()).reduce((prev, current) => {
prev[current[0]] = makeClassName(current[1]);
return prev;
}, {} as SerializableAnatomy['parts'])
}, {} as SerializableAnatomy["parts"])

return {
name: this.name,
Expand Down Expand Up @@ -167,7 +144,7 @@ function parseComponent(node: PluginUINodeData): Anatomy {
if (node.type === "COMPONENT_SET") {
if (node.children.length === 1) {
// Unlikely case
walkNode(node.children[0], componentName, "", anatomy);
walkNode(node.children[0], componentName, undefined, anatomy);
} else {
// Parse the component names into property and value sets
const properties = new Map<string, Array<string>>();
Expand Down Expand Up @@ -205,13 +182,13 @@ function parseComponent(node: PluginUINodeData): Anatomy {
});

// Handler for a single Component within a Set
const nodeHandler = (name: string, property: string): void => {
const nodeHandler = (name: string, property: string, value: string | boolean): void => {
const found = node.children.find(node => node.name.toLowerCase() === name.toLowerCase());
if (!found) {
// console.warn(`Expected component ${name}, property ${property}, not found`);
console.warn(`Expected component ${name}, property ${property}, not found`);
} else {
// console.log(" found node", name);
walkNode(found, componentName, property, anatomy);
// console.log("Handling node", {nodeName: name, condition: [property, value]});
walkNode(found, componentName, { [property]: value }, anatomy);
}
};

Expand All @@ -231,27 +208,27 @@ function parseComponent(node: PluginUINodeData): Anatomy {
const replaceProperty = `${property}=${condition.values[0]}`;
condition.values.forEach(value => {
const name = defaultName.replace(replaceProperty, `${property}=${value}`);
nodeHandler(name, camelCase(`${property} ${value}`));
nodeHandler(name, property, value);
});
}
});

// If there were no string conditions, process the first component as the default "baseline"
if (!foundString) {
walkNode(node.children[0], componentName, "", anatomy);
walkNode(node.children[0], componentName, undefined, anatomy);
}

// Handle boolean condition "true" values
anatomy.conditions.forEach((condition, property) => {
if (condition instanceof BooleanCondition) {
// Assume false is the default condition, find the `true` component
const name = defaultName.replace(`${property}=false`, `${property}=true`);
nodeHandler(name, property);
nodeHandler(name, property, true);
}
});
}
} else {
walkNode(node, componentName, "", anatomy);
walkNode(node, componentName, undefined, anatomy);
}

return anatomy;
Expand All @@ -262,44 +239,46 @@ function cleanNodeName(nodeName: string): string {
return nodeName.replace(/[^\x20-\x7F]/g, "").trim();
}

function walkNode(node: PluginUINodeData, componentName: string, condition: string, anatomy: Anatomy): void {
function walkNode(node: PluginUINodeData, componentName: string, condition: Record<string, string | boolean> | undefined, anatomy: Anatomy): void {
const nodeName = cleanNodeName(node.name);

if (nodeName === "Focus indicator") {
// Ignore for now
return;
}

if (node.type === "INSTANCE" && nodeName !== componentName) {
// TODO: This is too simplified, but it addresses many nested component issues for now.
return;
}

if (!node.name.endsWith(ignoreLayerName)) {
// TODO, not only frames, but what?
if (node.type === "FRAME" && nodeName !== componentName) {
anatomy.parts.add(node.name);
}

const styleRule = new StyleRule();

if (condition) {
styleRule.contextCondition = condition;
anatomy.parts.add(nodeName);
}

node.appliedStyleModules.forEach(style => {
const styleVariableName = style;
styleRule.styles.add(styleVariableName);
});
if (node.appliedStyleModules.length > 0 || node.appliedDesignTokens.size > 0) {
const styleRule = new StyleRule();

node.appliedDesignTokens.forEach((token, target) => {
const tokenRef = token.tokenID;
styleRule.tokens.add(new Token(target, tokenRef));
});
if (condition) {
styleRule.contextCondition = condition;
}

node.appliedStyleModules.forEach(style => {
styleRule.styles.add(style);
});

if (nodeName !== componentName) {
styleRule.part = nodeName;
}
node.appliedDesignTokens.forEach((token, target) => {
const tokenRef = token.tokenID;
styleRule.tokens.add(new Token(target, tokenRef));
});

if (styleRule.styles.size > 0 || styleRule.tokens.size > 0) {
if (nodeName !== componentName) {
anatomy.parts.add(node.name)
anatomy.parts.add(nodeName)
styleRule.part = nodeName;
}

anatomy.styleRules.add(styleRule);
}
}
Expand Down
4 changes: 4 additions & 0 deletions packages/adaptive-ui-designer-figma/src/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* The plugin data namespace used for Adaptive UI.
*/
export const FIGMA_SHARED_DATA_NAMESPACE: string = "adaptive_ui";
3 changes: 2 additions & 1 deletion packages/adaptive-ui-designer-figma/src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { Anatomy, Condition, BooleanCondition, StringCondition, StyleRule } from "./anatomy.js";
export { Anatomy, Condition, BooleanCondition, StringCondition, StyleRule } from "./anatomy.js";
export { FIGMA_SHARED_DATA_NAMESPACE } from "./constants.js";
Loading

0 comments on commit 50c6ba5

Please sign in to comment.