diff --git a/packages/figma-parser/package.json b/packages/figma-parser/package.json index db45d52..918e61d 100644 --- a/packages/figma-parser/package.json +++ b/packages/figma-parser/package.json @@ -26,7 +26,9 @@ "types": "./dist/index.d.ts", "module": "./dist/index.mjs", "devDependencies": { + "@types/mdast": "^4.0.1", "@types/node": "^20.8.0", + "@types/picomatch": "^2.3.2", "typescript": "^5.0.2", "vite": "^4.4.9", "vite-plugin-dts": "^3.6.0", @@ -34,9 +36,7 @@ "wsrun": "^5.2.4" }, "dependencies": { - "@types/mdast": "^4.0.1", - "@types/node": "^20.7.1", - "@types/picomatch": "^2.3.2", + "@figma/rest-api-spec": "^0.10.0", "@typescript-eslint/eslint-plugin": "^6.13.2", "@typescript-eslint/parser": "^6.13.2", "flat": "^6.0.1", @@ -45,7 +45,7 @@ "mdast-util-to-markdown": "^2.1.0", "picomatch": "^2.3.1", "remark": "^15.0.1", - "type-fest": "^4.8.3", + "type-fest": "^4.12.0", "unist-builder": "^4.0.0" }, "publishConfig": { diff --git a/packages/figma-parser/src/collection.ts b/packages/figma-parser/src/collection.ts deleted file mode 100644 index aeab525..0000000 --- a/packages/figma-parser/src/collection.ts +++ /dev/null @@ -1,91 +0,0 @@ -interface CollectionItem { - toString(): string -} - -export abstract class Collection { - public readonly length: number = 0; - - [i: number]: Item; - - constructor(nodes?: Input) - constructor(nodes?: Item[]) { - if (!nodes) return - let length = 0; - - nodes.forEach((item, index) => { - this[index] = item - length++; - }); - - this.length = length; - } - - abstract table(): void - - toString() { - const out: string[] = []; - for (let i = 0; i <= this.length - 1; i++) { - out.push(this[i].toString()); - } - return out.join(", "); - } - - at(index: number) { - if (index > 0 && index > this.length - 1) - throw new Error( - `Maximum index for this collection is ${this.length - 1}`, - ); - if (index < 0 && index < -this.length) - throw new Error(`Minimum index for this collection is ${-this.length}`); - - if (index < 0) { - return this[this.length + index]; - } - - return this[index]; - } - - find(predicate: (item: Item, index: number, collection: typeof this) => boolean): Item | undefined { - for (let i = 0; i <= this.length - 1; i++) { - if (predicate(this[i], i, this)) { - return this[i]; - } - } - return - } - - protected _filter(predicate: T): Item[] - protected _filter(predicate: (item: Item, index: number, collection: typeof this) => boolean): Item[] { - const out: Item[] = []; - - for (let i = 0; i <= this.length - 1; i++) { - if (predicate(this[i], i, this)) { - out.push(this[i]); - } - } - - return out - } - - protected _map(callback: (item: Item, index: number, collection: typeof this) => T): T[] { - const out: T[] = []; - - for (let i = 0; i <= this.length - 1; i++) { - out.push(callback(this[i], i, this)); - } - - return out; - } - - forEach(callback: (item: Item, index: number, collection: typeof this) => void): void { - for (let i = 0; i <= this.length - 1; i++) { - callback(this[i], i, this); - } - } - - *[Symbol.iterator]() { - for (let i = 0; i <= this.length - 1; i++) { - yield this[i]; - } - } -} diff --git a/packages/figma-parser/src/index.ts b/packages/figma-parser/src/index.ts index d24bc33..13ab7d0 100644 --- a/packages/figma-parser/src/index.ts +++ b/packages/figma-parser/src/index.ts @@ -2,7 +2,7 @@ import { FigmaParser, FigmaParserOptions, FigmaPAT } from './parser'; export * from './parser/index'; export * from './plugins/styles/index'; -export * from './types' +export * from './types'; // decode-named-character-reference dependency of one of mdast plugins causes errors // by introducing unnecesary document.createElement() calls. @@ -15,5 +15,4 @@ export default function (token: FigmaPAT, options?: Partial) return new FigmaParser(token, options) as FigmaParser; } - -export { VariablesPlugin } from './plugins/variables/variables.plugin' +export { VariablesPlugin } from './plugins/variables/variables.plugin'; diff --git a/packages/figma-parser/src/parser/parser.ts b/packages/figma-parser/src/parser/parser.ts index 73ec0e7..5e94882 100644 --- a/packages/figma-parser/src/parser/parser.ts +++ b/packages/figma-parser/src/parser/parser.ts @@ -1,6 +1,8 @@ import { NodeCollectionMixin, NodeMixin, NodesPlugin, SingleNode } from '../plugins/nodes'; import { StylesPlugin } from '../plugins/styles'; import { StylesProcessor } from '../plugins/styles/styles-processor'; +import { CollectionsSet } from '../plugins/variables/collections-set'; +import { VariablesPlugin } from '../plugins/variables/variables.plugin'; import { HardCache } from './hard-cache'; import { loggerFactory } from './logger'; import { FigmaParserPlugin, FigmaParserPluginConstructor, FigmaParserPluginFunction, FigmaParserPluginInterface } from './types'; @@ -29,7 +31,7 @@ export class FigmaParser { cache: HardCache; readonly options: FigmaParserOptions = { - plugins: [NodesPlugin, StylesPlugin], + plugins: [NodesPlugin, StylesPlugin, VariablesPlugin], nodeMixins: [], nodeCollectionMixins: [], hardCache: true, @@ -114,4 +116,10 @@ export class FigmaParser { return plugin.styles(fileId); } + + async variables(fileId: string, published: boolean = true): Promise { + const plugin = this.plugins.get('variables-plugin')! as ReturnType; + + return plugin.variables(fileId, published); + } } diff --git a/packages/figma-parser/src/plugins/styles/design-tokens.transformer.spec.ts b/packages/figma-parser/src/plugins/styles/design-tokens.transformer.spec.ts index 129b9bf..69aeff3 100644 --- a/packages/figma-parser/src/plugins/styles/design-tokens.transformer.spec.ts +++ b/packages/figma-parser/src/plugins/styles/design-tokens.transformer.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'vitest'; -import { ColorTokenValue, DesignTokens, GradientStop, GradientTokenValue, ShadowStop, ShadowTokenValue, TypographyTokenValue } from './design-tokens.transformer'; +import { ColorTokenValue, GradientStop, GradientTokenValue, ShadowStop, ShadowTokenValue, TypographyTokenValue } from '../../shared/design-tokens-format.types'; +import { DesignTokens } from './design-tokens.transformer'; import { DEFINITIONS_FIXTURE } from './tests/definitions.fixture'; describe('Design Tokens Transformer', () => { @@ -10,13 +11,13 @@ describe('Design Tokens Transformer', () => { describe('Solid Color Token', () => { test('Should transform solid color definition', async () => { const output = DesignTokens()(DEFINITIONS_FIXTURE); - const colorToken = output['color/token']['$value']; + const colorToken = output['color.token']['$value']; expect(colorToken).toBeTypeOf('string'); }); test('Should take alpha value from parent definition', async () => { const output = DesignTokens()(DEFINITIONS_FIXTURE); - const alphaHexValue = output['color/token']['$value'] as ColorTokenValue; + const alphaHexValue = output['color.token']['$value'] as ColorTokenValue; const fillOpacity = 0.5; // ( FILE_NODES_FIXTURE['1171:10749'].document.fills[0].opacity ) const hexOpacity = Math.round(fillOpacity * 255) @@ -36,7 +37,7 @@ describe('Design Tokens Transformer', () => { const designTokens = DesignTokens(); const output = designTokens(DEFINITIONS_FIXTURE); - const gradientToken = output['gradient/token']['$value'] as GradientTokenValue; + const gradientToken = output['gradient.token']['$value'] as GradientTokenValue; gradientToken.forEach((gradientStop: GradientStop) => expect(gradientStop).toMatchSchema(valueSchema)); }); @@ -53,7 +54,7 @@ describe('Design Tokens Transformer', () => { }; const output = DesignTokens()(DEFINITIONS_FIXTURE); - const shadowToken = output['shadow/token']['$value'] as ShadowTokenValue; + const shadowToken = output['shadow.token']['$value'] as ShadowTokenValue; shadowToken.forEach((shadow: ShadowStop) => expect(shadow).toMatchSchema(valueSchema)); }); @@ -70,7 +71,7 @@ describe('Design Tokens Transformer', () => { }; const output = DesignTokens()(DEFINITIONS_FIXTURE); - const textToken = output['text/token']['$value'] as TypographyTokenValue; + const textToken = output['text.token']['$value'] as TypographyTokenValue; expect(textToken).toMatchSchema(valueSchema); }); diff --git a/packages/figma-parser/src/plugins/styles/design-tokens.transformer.ts b/packages/figma-parser/src/plugins/styles/design-tokens.transformer.ts index 9c83629..3ff2c6d 100644 --- a/packages/figma-parser/src/plugins/styles/design-tokens.transformer.ts +++ b/packages/figma-parser/src/plugins/styles/design-tokens.transformer.ts @@ -1,75 +1,9 @@ +import { DesignToken, DesignTokensFormat, DesignTokensFormatDeep, DesignTokensFormatFlat } from '../../shared/design-tokens-format.types'; +import { entriesToDeepObject } from '../../shared/entriesToDeepObject.util'; import { rgbaToHexa } from '../../shared/rgba-to-hex.util'; import type { Effect, Paint, TypeStyle } from '../../types'; import { FigmaStyleDfeinition, FigmaStylesTransformer, isEffectDefinition, isFillDefinition, isTextDefinition } from './types'; -export interface TypographyTokenValue { - fontFamily: string; - fontSize: number; - fontWeight: number; - letterSpacing: number; - lineHeight: number; -} - -export interface GradientStop { - color: string; - position: number; -} - -export type ColorTokenValue = `#${string}`; - -export interface ShadowStop { - color: string; - offsetX: number; - offsetY: number; - blur: number; - spread: number; -} - -export type ShadowTokenValue = ShadowStop[]; - -export type GradientTokenValue = GradientStop[]; - -export interface DesignToken { - $type: 'typography' | 'color' | 'shadow' | 'gradient'; - $value: TypographyTokenValue | ColorTokenValue | GradientTokenValue | ShadowTokenValue; -} - -export interface ColorToken extends DesignToken { - $type: 'color'; - $value: ColorTokenValue; -} - -export interface TypographyToken extends DesignToken { - $type: 'typography'; - $value: TypographyTokenValue; -} - -export interface ShadowToken extends DesignToken { - $type: 'shadow'; - $value: ShadowTokenValue; -} - -export interface GradientToken extends DesignToken { - $type: 'gradient'; - $value: GradientTokenValue; -} - -export const isShadowToken = (token: DesignToken): token is GradientToken => token.$type === 'shadow'; - -export const isColorToken = (token: DesignToken): token is ColorToken => token.$type === 'color'; - -export const isTypographyToken = (token: DesignToken): token is TypographyToken => token.$type === 'typography'; - -export const isGradientToken = (token: DesignToken): token is GradientToken => token.$type === 'gradient'; - -export interface DesignTokensFormatFlat { - [k: string]: DesignToken; -} - -export interface DesignTokensFormatDeep { - [k: string]: DesignTokensFormatDeep; -} - const gradientTransform = (style: Paint) => { if (!style || !style.gradientStops) throw new Error('Expected Paint style with gradientStops definitions!'); return style.gradientStops.map((stop) => ({ @@ -113,15 +47,18 @@ const shadowTransform = (style: Effect[]) => { }); }; -export function DesignTokens (deep: true): FigmaStylesTransformer; -export function DesignTokens (deep: false): FigmaStylesTransformer; -export function DesignTokens (deep: boolean = false): FigmaStylesTransformer { +export function DesignTokens(): FigmaStylesTransformer; +export function DesignTokens(deep: true): FigmaStylesTransformer; +export function DesignTokens(deep: false): FigmaStylesTransformer; +export function DesignTokens(deep?: boolean): FigmaStylesTransformer; +export function DesignTokens(deep: boolean = false): FigmaStylesTransformer { return (input: FigmaStyleDfeinition[]) => { const stylesArray = input .map((definition) => { + const tokenName = definition.name.replaceAll('/', '.').replaceAll(' ', '-'); if (isFillDefinition(definition) && definition.definition[0].type === 'SOLID') { return [ - definition.name, + tokenName, { $type: 'color', $value: solidTransform(definition.definition[0]), @@ -131,7 +68,7 @@ export function DesignTokens (deep: boolean = false): FigmaStylesTransformer { - const path = name.split('/'); - path.reduce((acc: Record, key: string, i: number) => { - if (acc[key] === undefined) acc[key] = {}; - - if (i === path.length - 1) { - acc[key] = { - ...acc[key], - ...value, - }; - } - - return acc[key]; - }, output); - }); - - return output; + entriesToDeepObject(stylesArray); } + return Object.fromEntries(stylesArray); }; -}; +} diff --git a/packages/figma-parser/src/plugins/styles/styles-processor.ts b/packages/figma-parser/src/plugins/styles/styles-processor.ts index cf282c2..a254879 100644 --- a/packages/figma-parser/src/plugins/styles/styles-processor.ts +++ b/packages/figma-parser/src/plugins/styles/styles-processor.ts @@ -1,5 +1,6 @@ +import { DesignTokensFormat, DesignTokensFormatDeep, DesignTokensFormatFlat } from '../../shared/design-tokens-format.types'; import { Effect, FileNodesResponse, FullStyleMetadata, Last, Node, Paint, TypeStyle } from '../../types'; -import { DesignTokens, DesignTokensFormat } from './design-tokens.transformer'; +import { DesignTokens } from './design-tokens.transformer'; import { EffectStyle, FigmaStyleDfeinition, FigmaStylesTransformer, FillStyle, FullStyle, TextStyle, isEffectStyle, isFillStyle, isTextStyle } from './types'; export class StylesProcessor { @@ -47,10 +48,15 @@ export class StylesProcessor { })); } - designTokens(deep = false): DesignTokensFormat { - return this.transform(DesignTokens(deep)) as DesignTokensFormat; + designTokens(): DesignTokensFormatFlat; + designTokens(deep: true): DesignTokensFormatDeep; + designTokens(deep: false): DesignTokensFormatFlat; + designTokens(deep?: boolean): DesignTokensFormat { + return this.transform(DesignTokens(deep)); } + transform(): FigmaStyleDfeinition[]; + transform(...transformers: Transformers): ReturnType>; transform(...transformers: Transformers): ReturnType> | FigmaStyleDfeinition[] { if (transformers.length === 0) return this.definitions(); return transformers.reduce((acc, transformer) => transformer(acc), this.definitions()); diff --git a/packages/figma-parser/src/plugins/variables/collections-set.ts b/packages/figma-parser/src/plugins/variables/collections-set.ts index 9ae1c03..240a55c 100644 --- a/packages/figma-parser/src/plugins/variables/collections-set.ts +++ b/packages/figma-parser/src/plugins/variables/collections-set.ts @@ -1,10 +1,26 @@ -import { Collection } from '../../collection' -import { FigmaVariable } from './variable' -import { FigmaVariableCollection } from './variable-collection' +import { LocalVariable, LocalVariableCollection } from '@figma/rest-api-spec'; +import { FigmaLocalVariableCollection } from './variable-collection'; -export class CollectionsSet extends Collection{ - constructor(collections: FigmaVariableCollection[]) { - super(collections) +export class CollectionsSet { + length: number = 0; + [i: number]: FigmaLocalVariableCollection; + + constructor(collections: { [key: string]: LocalVariableCollection }, variablesRef: { [key: string]: LocalVariable }) { + if (!collections) return; + let length = 0; + + Object.values(collections).map((collection, index) => { + const collectionInstance = new FigmaLocalVariableCollection(collection, this); + + collection.variableIds.forEach((id) => { + collectionInstance.push(variablesRef[id]); + }); + + this[index] = collectionInstance; + length++; + }); + + this.length = length; } table(): void { @@ -18,21 +34,66 @@ export class CollectionsSet extends Collection{ console.table(lines); } - publishable() { - const items = this._filter(collection => !collection.hiddenFromPublishing) - return new CollectionsSet(items) + getByName(name: string) { + return this.find((collection) => collection.name === name); } - getByName(name: string) { - return this.find((collection) => collection.name === name) + getVariableById(id: string) { + for (let i = 0; i <= this.length - 1; i++) { + const variable = this[i].find((variable) => variable.id === id); + if (variable) return variable; + } + + throw new Error(`Couldn't find variable with id: ${id}`); + } + + find(predicate: (item: FigmaLocalVariableCollection, index: number, collection: CollectionsSet) => boolean): FigmaLocalVariableCollection | undefined { + for (let i = 0; i <= this.length - 1; i++) { + if (predicate(this[i], i, this)) { + return this[i]; + } + } + return; + } + + filter(predicate: (item: FigmaLocalVariableCollection, index: number, collection: typeof this) => boolean): FigmaLocalVariableCollection[] { + const out: FigmaLocalVariableCollection[] = []; + + for (let i = 0; i <= this.length - 1; i++) { + if (predicate(this[i], i, this)) { + out.push(this[i]); + } + } + + return out; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + map(callback: (item: FigmaLocalVariableCollection, index: number, collection: typeof this) => T): T[] { + const out: T[] = []; + + for (let i = 0; i <= this.length - 1; i++) { + out.push(callback(this[i], i, this)); + } + + return out; + } + + at(index: number) { + if (index > 0 && index > this.length - 1) throw new Error(`Maximum index for this collection is ${this.length - 1}`); + if (index < 0 && index < -this.length) throw new Error(`Minimum index for this collection is ${-this.length}`); + + if (index < 0) { + return this[this.length + index]; + } + + return this[index]; } - findVariable(predicate: (variable: FigmaVariable) => boolean) { + forEach(callback: (item: FigmaLocalVariableCollection, index: number, collection: typeof this) => void): void { for (let i = 0; i <= this.length - 1; i++) { - const variable = this[i].findVariable(predicate) - if (variable) return variable + callback(this[i], i, this); } - return } *[Symbol.iterator]() { @@ -40,5 +101,4 @@ export class CollectionsSet extends Collection{ yield this[i]; } } - } diff --git a/packages/figma-parser/src/plugins/variables/types.ts b/packages/figma-parser/src/plugins/variables/types.ts deleted file mode 100644 index bfcea13..0000000 --- a/packages/figma-parser/src/plugins/variables/types.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Color } from '../../full-figma-types' - -export interface VariableResponse { - status: number - error: boolean - meta: { - variables: Record; - variableCollections: Record; - } -} - - -/** - * A grouping of related Variable objects each with the same modes. - */ -export interface VariableCollection { - /** - * The unique identifier of this variable collection. - */ - id: string - - /** - * The name of this variable collection. - */ - name: string - - /** - * The key of the variable collection. - */ - key: string - - /** - * The list of modes defined for this variable collection. - */ - modes: { modeId: string, name: string }[] - - /** - * The id of the default mode. - */ - defaultModeId: string - - /** - * Whether the variable collection is remote. - */ - remote: boolean - - /** - * Whether this variable collection is hidden when publishing the current file as a library. - */ - hiddenFromPublishing: boolean - - /** - * The ids of the variables in the collection. Note that the order of these variables is roughly the same as what is shown in Figma Design, however it does not account for groups. As a result, the order of these variables may not exactly reflect the exact ordering and grouping shown in the authoring UI. - */ - variableIds: string[] -} - -export interface VariableAlias { - type: "VARIABLE_ALIAS" - id: string -} - -export type VariableScope = 'ALL_SCOPES' | 'TEXT_CONTENT' | 'WIDTH_HEIGHT' | 'GAP' | 'ALL_SCOPES' | 'ALL_FILLS' | 'FRAME_FILL' | 'SHAPE_FILL' | 'TEXT_FILL' | 'STROKE_COLOR' - -export interface CodeSyntax { - WEB: string - ANDROID: string - iOS: string -} - -/** - * A Variable is a single design token that defines values for each of the modes in its VariableCollection. These values can be applied to various kinds of design properties. - */ -export interface Variable { - /** - * The unique identifier of this variable. - */ - id: string - - /** - * The name of this variable. - */ - name: string - - /** - * The key of the variable. - */ - key: string - - /** - * The id of the variable collection that contains this variable. - */ - variableCollectionId: string - - /** - * The resolved type of the variable. - */ - resolvedType: "BOOLEAN" | "FLOAT" | "STRING" | "COLOR" - - /** - * The values for each mode of this variable. - */ - valuesByMode: Record - - /** - * Whether the variable is remote. - */ - remote: boolean - - /** - * Description of this variable. - */ - description: string - - /** - * Whether this variable is hidden when publishing the current file as a library. - * If the parent VariableCollection is marked as hiddenFromPublishing, then this variable will also be hidden from publishing via the UI. hiddenFromPublishing is independently toggled for a variable and collection. However, both must be true for a given variable to be publishable. - */ - hiddenFromPublishing: boolean - - /** - * An array of scopes in the UI where this variable is shown. Setting this property will show/hide this variable in the variable picker UI for different fields. - * - * Setting scopes for a variable does not prevent that variable from being bound in other scopes (for example, via the Plugin API). This only limits the variables that are shown in pickers within the Figma UI. - */ - scopes: VariableScope[] - - /** - * Code syntax definitions for this variable. Code syntax allows you to represent variables in code using platform-specific names, and will appear in Dev Mode's code snippets when inspecting elements using the variable. - */ - codeSyntaxVariable: CodeSyntax - -} diff --git a/packages/figma-parser/src/plugins/variables/variable-collection.ts b/packages/figma-parser/src/plugins/variables/variable-collection.ts index 4ae242b..baab074 100644 --- a/packages/figma-parser/src/plugins/variables/variable-collection.ts +++ b/packages/figma-parser/src/plugins/variables/variable-collection.ts @@ -1,72 +1,201 @@ -import { Collection } from '../../collection' -import { CollectionsSet } from './collections-set' -import { Variable, VariableCollection } from './types' -import { FigmaVariable } from './variable' +import { LocalVariable, LocalVariableCollection, RGBA } from '@figma/rest-api-spec'; +import { DesignToken, DesignTokenType, DesignTokensFormat, DesignTokensFormatDeep, DesignTokensFormatFlat } from '../../shared/design-tokens-format.types'; +import { entriesToDeepObject } from '../../shared/entriesToDeepObject.util'; +import { rgbaToHexa } from '../../shared/rgba-to-hex.util'; +import { CollectionsSet } from './collections-set'; +import { FigmaLocalVariable } from './variable'; -export class FigmaVariableCollection extends Collection { - public readonly length: number = 0; +const resolveTokenType = (variable: FigmaLocalVariable): DesignTokenType => { + if (variable.resolvedType === 'COLOR') return 'color'; - constructor(variables: FigmaVariable[] | Variable[], private readonly meta: VariableCollection) { - super(); - let length = 0; + if (variable.resolvedType === 'FLOAT' && variable.scopes.includes('WIDTH_HEIGHT')) return 'dimension'; - variables.forEach((variable, index) => { - this[index] = variable instanceof FigmaVariable ? variable : new FigmaVariable(variable, this); - length++; - }); + if (variable.resolvedType === 'FLOAT') return 'number'; - this.length = length; - } + return 'unknown'; +}; + +const resolvedTokenValue = (variable: FigmaLocalVariable, mode: string): string | number | boolean => { + const value = variable.resolveValue(mode); + + if (variable.resolvedType === 'COLOR') return rgbaToHexa(value as RGBA); + + return value as string | number | boolean; +}; + +const tokenValue = (variable: FigmaLocalVariable, mode: string): string | number | boolean => { + return variable.value(mode) as string | number | boolean; +}; + +interface DesignTokensTransformOptions { + resolveAliases: boolean; + deep: boolean; +} + +class Data { + constructor(readonly raw: LocalVariableCollection) {} get name() { - return this.meta.name + return this.raw.name; } get id() { - return this.meta.id + return this.raw.id; } get key() { - return this.meta.key + return this.raw.key; } get modes() { - return this.meta.modes + return this.raw.modes; } get defaultModeId() { - return this.meta.defaultModeId + return this.raw.defaultModeId; } get remote() { - return this.meta.remote + return this.raw.remote; } get hiddenFromPublishing() { - return this.meta.hiddenFromPublishing + return this.raw.hiddenFromPublishing; + } +} + +export class FigmaLocalVariableCollection extends Data { + length: number = 0; + [i: number]: FigmaLocalVariable; + + constructor( + readonly raw: LocalVariableCollection, + public setRef: CollectionsSet, + variables?: FigmaLocalVariable[] | LocalVariable[] + ) { + super(raw); + let length = 0; + + if (variables && Array.isArray(variables) && variables.length > 0) { + variables.forEach((variable, index) => { + this[index] = variable instanceof FigmaLocalVariable ? variable : new FigmaLocalVariable(variable, this); + length++; + }); + } + + this.length = length; + } + + push(variable: LocalVariable | FigmaLocalVariable) { + const localVariable = variable instanceof FigmaLocalVariable ? variable.raw : variable; + this[this.length] = new FigmaLocalVariable(localVariable, this); + this.length++; } table() { const lines = Array.from(this).map((variable) => ({ name: variable.id, id: variable?.name, - type: variable?.value, + type: variable?.resolvedType, hiddenFromPublishing: variable?.hiddenFromPublishing, })); console.table(lines); } - findVariable(predicate: (variable: FigmaVariable) => boolean) { - return this.find(predicate) + modeExists(modeName: string) { + return !!this.raw.modes.find(({ name }) => name === modeName); + } + + getModeId(name?: string) { + return this.raw.modes.find((mode) => mode.name === name)?.modeId || this.raw.defaultModeId; + } + + designTokensByMode(mode: string): DesignTokensFormatFlat; + designTokensByMode(mode: string, userOptions: { deep: false }): DesignTokensFormatFlat; + designTokensByMode(mode: string, userOptions: { deep: true }): DesignTokensFormatDeep; + designTokensByMode(mode: string, userOptions?: Partial): DesignTokensFormat { + const options: DesignTokensTransformOptions = { + resolveAliases: true, + deep: false, + ...userOptions, + }; + + const output: [string, DesignToken][] = []; + + for (let i = 0; i <= this.length - 1; i++) { + const variable = this[i]; + const token: DesignToken = { + $type: resolveTokenType(variable), + $value: options.resolveAliases ? resolvedTokenValue(variable, mode) : tokenValue(variable, mode), + }; + + if (variable.description) { + token.$description = variable.description; + } + + output.push([variable.name.replaceAll('/', '.').replaceAll(' ', '-'), token]); + } + + if (options.deep) { + return entriesToDeepObject(output); + } + + return Object.fromEntries(output); + } + + find(predicate: (item: FigmaLocalVariable, index: number, collection: typeof this) => boolean): FigmaLocalVariable | undefined { + for (let i = 0; i <= this.length - 1; i++) { + if (predicate(this[i], i, this)) { + return this[i]; + } + } + return; + } + + filter(predicate: (item: FigmaLocalVariable, index: number, collection: FigmaLocalVariableCollection) => boolean): FigmaLocalVariableCollection { + const out: FigmaLocalVariable[] = []; + + for (let i = 0; i <= this.length - 1; i++) { + if (predicate(this[i], i, this)) { + out.push(this[i]); + } + } + + return new FigmaLocalVariableCollection(this.raw, this.setRef, out); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + map(callback: (item: FigmaLocalVariable, index: number, collection: FigmaLocalVariableCollection) => T): T[] { + const out: T[] = []; + + for (let i = 0; i <= this.length - 1; i++) { + out.push(callback(this[i], i, this)); + } + + return out; + } + + at(index: number) { + if (index > 0 && index > this.length - 1) throw new Error(`Maximum index for this collection is ${this.length - 1}`); + if (index < 0 && index < -this.length) throw new Error(`Minimum index for this collection is ${-this.length}`); + + if (index < 0) { + return this[this.length + index]; + } + + return this[index]; } - filter(predicate: (collection: FigmaVariableCollection, index: number, set: CollectionsSet) => boolean) { - const items = this._filter(predicate) - return new FigmaVariableCollection(items, this.meta); + forEach(callback: (item: FigmaLocalVariable, index: number, collection: FigmaLocalVariableCollection) => void): void { + for (let i = 0; i <= this.length - 1; i++) { + callback(this[i], i, this); + } } - getByName(name: string): FigmaVariable | undefined { - return this.find(variable => variable.name === name) + *[Symbol.iterator]() { + for (let i = 0; i <= this.length - 1; i++) { + yield this[i]; + } } } diff --git a/packages/figma-parser/src/plugins/variables/variable.ts b/packages/figma-parser/src/plugins/variables/variable.ts index 53d922d..fbcc7a6 100644 --- a/packages/figma-parser/src/plugins/variables/variable.ts +++ b/packages/figma-parser/src/plugins/variables/variable.ts @@ -1,69 +1,65 @@ -import { Color } from '../../full-figma-types' -import { CodeSyntax, Variable, VariableAlias, VariableCollection, VariableScope } from './types' -import { FigmaVariableCollection } from './variable-collection' +import { LocalVariable, RGBA, VariableAlias, VariableCodeSyntax, VariableScope } from '@figma/rest-api-spec'; +import { FigmaLocalVariableCollection } from './variable-collection'; -export class FigmaVariable { - constructor(private original: Variable, public collection: FigmaVariableCollection) { - } +export const isVariableAlias = (value: unknown): value is VariableAlias => !!value && typeof value === 'object' && 'type' in value && value.type === 'VARIABLE_ALIAS'; - table() { - console.table(this.original) - } +class Data implements LocalVariable { + constructor(public raw: LocalVariable) {} /** * The unique identifier of this variable. */ get id(): string { - return this.original.id; + return this.raw.id; } /** * The name of this variable. */ get name(): string { - return this.original.name; + return this.raw.name; } /** * The key of the variable. */ get key(): string { - return this.original.key; + return this.raw.key; } /** * The id of the variable collection that contains this variable. */ get variableCollectionId(): string { - return this.original.variableCollectionId; + return this.raw.variableCollectionId; } /** * The resolved type of the variable. */ - get resolvedType(): "BOOLEAN" | "FLOAT" | "STRING" | "COLOR" { - return this.original.resolvedType; + get resolvedType(): 'BOOLEAN' | 'FLOAT' | 'STRING' | 'COLOR' { + return this.raw.resolvedType; } /** * The values for each mode of this variable. */ - get valuesByMode(): Record { - return this.original.valuesByMode; + get valuesByMode(): Record { + return this.raw.valuesByMode; } /** * Whether the variable is remote. */ get remote(): boolean { - return this.original.remote; + return this.raw.remote; } /** * Description of this variable. */ get description(): string { - return this.original.description; + return this.raw.description; } /** @@ -71,7 +67,7 @@ export class FigmaVariable { * If the parent VariableCollection is marked as hiddenFromPublishing, then this variable will also be hidden from publishing via the UI. hiddenFromPublishing is independently toggled for a variable and collection. However, both must be true for a given variable to be publishable. */ get hiddenFromPublishing(): boolean { - return this.original.hiddenFromPublishing; + return this.raw.hiddenFromPublishing; } /** @@ -80,15 +76,74 @@ export class FigmaVariable { * Setting scopes for a variable does not prevent that variable from being bound in other scopes (for example, via the Plugin API). This only limits the variables that are shown in pickers within the Figma UI. */ get scopes(): VariableScope[] { - return this.original.scopes; + return this.raw.scopes; } /** * Code syntax definitions for this variable. Code syntax allows you to represent variables in code using platform-specific names, and will appear in Dev Mode's code snippets when inspecting elements using the variable. */ - get codeSyntaxVariable(): CodeSyntax { - return this.original.codeSyntaxVariable; + get codeSyntax(): VariableCodeSyntax { + return this.raw.codeSyntax; } - } +export class FigmaLocalVariable extends Data { + constructor( + public raw: LocalVariable, + public collection: FigmaLocalVariableCollection + ) { + super(raw); + } + + table() { + console.table(this.raw); + } + + hasValueForMode(name: string) { + return !!this.valueByMode(name); + } + + valueByMode(modeName?: string) { + if (!modeName) return this.defaultValue(); + + const modeId = this.collection.getModeId(modeName); + + if (!modeId) return this.defaultValue(); + + return this.raw.valuesByMode[modeId]; + } + + defaultValue() { + return this.valuesByMode[this.collection.defaultModeId]; + } + + resolveAliasValueForMode(alias: VariableAlias, name: string): string | number | boolean | RGBA { + const aliassedVariable = this.collection.setRef.getVariableById(alias.id); + + const value = aliassedVariable.valueByMode(name); + + if (!isVariableAlias(value)) return value; + + return aliassedVariable.resolveAliasValueForMode(value as VariableAlias, name); + } + + resolveValue(name: string): string | number | boolean | RGBA { + const value = this.valueByMode(name); + + if (isVariableAlias(value)) return this.resolveAliasValueForMode(value, name); + + return value; + } + + value(name: string): string | number | boolean | RGBA { + const value = this.valueByMode(name); + + if (isVariableAlias(value)) { + const aliasedVariable = this.collection.setRef.getVariableById(value.id); + + return `{${aliasedVariable.name.replaceAll('/', '.')}}`; + } + + return value; + } +} diff --git a/packages/figma-parser/src/plugins/variables/variables.plugin.ts b/packages/figma-parser/src/plugins/variables/variables.plugin.ts index 8567db8..22fbb8d 100644 --- a/packages/figma-parser/src/plugins/variables/variables.plugin.ts +++ b/packages/figma-parser/src/plugins/variables/variables.plugin.ts @@ -1,53 +1,20 @@ -import { Collection } from '../../collection' -import { FigmaParser } from '../../parser' -import { FigmaParserPlugin } from '../../types' -import { CallbackFunction, NodeCollection, SingleNode } from '../document' -import { CollectionsSet } from './collections-set' -import { Variable, VariableCollection, VariableResponse } from './types' -import { FigmaVariable } from './variable' -import { FigmaVariableCollection } from './variable-collection' +import { GetLocalVariablesResponse } from '@figma/rest-api-spec'; +import { FigmaParser, FigmaParserPluginInterface } from '../../parser'; +import { CollectionsSet } from './collections-set'; +type VariablesPluginInterface = { + variables: (fileId: string, local?: boolean) => Promise; +}; -export class VariablesPlugin implements FigmaParserPlugin { - private host: FigmaParser; +export function VariablesPlugin(host: FigmaParser): FigmaParserPluginInterface { + return { + name: 'variables-plugin', + async variables(fileId: string) { + const localVariablesUrl = `files/${fileId}/variables/local`; - constructor(host: FigmaParser) { - this.host = host; - this.host.variables = this.variables.bind(this); - } + const { variables, variableCollections } = await host.request(localVariablesUrl).then((response) => response.meta); - async variables(fileId: string, local = true) { - const localVariablesUrl = `files/${fileId}/variables/local` - const publishedVariablesUrl = `files/${fileId}/variables/published` - - const url = local ? localVariablesUrl : publishedVariablesUrl; - - const { variables, variableCollections } = await this.host - .request(url) - .then((response) => response.meta); - - let groupedVariables: Record = {} - - Object.values(variables).forEach((variable) => { - const collectionId = variable.variableCollectionId - if ( !groupedVariables[collectionId] ) { - groupedVariables[collectionId] = [] - } - - groupedVariables[collectionId].push(variable) - }) - - const output = Object.entries(groupedVariables).map(([collectionId, variables]) => { - return new FigmaVariableCollection(variables, variableCollections[collectionId]) - }) - - return new CollectionsSet(output) - } -} - - -declare module "../../parser" { - export interface FigmaParser { - variables(fileId: string): any; - } + return new CollectionsSet(variableCollections, variables); + }, + }; } diff --git a/packages/figma-parser/src/shared/design-tokens-format.types.ts b/packages/figma-parser/src/shared/design-tokens-format.types.ts new file mode 100644 index 0000000..c63daf5 --- /dev/null +++ b/packages/figma-parser/src/shared/design-tokens-format.types.ts @@ -0,0 +1,85 @@ +export interface TypographyTokenValue { + fontFamily: string; + fontSize: number; + fontWeight: number; + letterSpacing: number; + lineHeight: number; +} + +export interface GradientStop { + color: string; + position: number; +} + +export type ColorTokenValue = `#${string}`; + +export interface ShadowStop { + color: string; + offsetX: number; + offsetY: number; + blur: number; + spread: number; +} + +export type ShadowTokenValue = ShadowStop[]; + +export type GradientTokenValue = GradientStop[]; + +export type DesignTokenType = 'unknown' | 'typography' | 'color' | 'shadow' | 'gradient' | 'number' | 'dimension'; + +export interface DesignTokenBase { + $type: DesignTokenType; + $description?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + $value: any; +} + +export interface ColorToken extends DesignTokenBase { + $type: 'color'; + $value: ColorTokenValue; +} + +export interface TypographyToken extends DesignTokenBase { + $type: 'typography'; + $value: TypographyTokenValue; +} + +export interface ShadowToken extends DesignTokenBase { + $type: 'shadow'; + $value: ShadowTokenValue; +} + +export interface GradientToken extends DesignTokenBase { + $type: 'gradient'; + $value: GradientTokenValue; +} + +export interface NumberToken extends DesignTokenBase { + $type: 'number'; + $value: number; +} + +export interface DimensionToken extends DesignTokenBase { + $type: 'dimension'; + $value: string; +} + +export type DesignToken = ColorToken | TypographyToken | ShadowToken | GradientToken | NumberToken | DimensionToken | DesignTokenBase; + +export const isShadowToken = (token: DesignToken): token is GradientToken => token.$type === 'shadow'; + +export const isColorToken = (token: DesignToken): token is ColorToken => token.$type === 'color'; + +export const isTypographyToken = (token: DesignToken): token is TypographyToken => token.$type === 'typography'; + +export const isGradientToken = (token: DesignToken): token is GradientToken => token.$type === 'gradient'; + +export interface DesignTokensFormatFlat { + [k: string]: DesignToken; +} + +export interface DesignTokensFormatDeep { + [k: string]: DesignTokensFormatDeep; +} + +export type DesignTokensFormat = DesignTokensFormatDeep | DesignTokensFormatFlat; diff --git a/packages/figma-parser/src/shared/entriesToDeepObject.util.ts b/packages/figma-parser/src/shared/entriesToDeepObject.util.ts new file mode 100644 index 0000000..4113c99 --- /dev/null +++ b/packages/figma-parser/src/shared/entriesToDeepObject.util.ts @@ -0,0 +1,22 @@ +export const entriesToDeepObject = (input: [string, object][], separator = '.') => { + const output = {}; + + input.forEach(([name, value]) => { + const path = name.split(separator); + + path.reduce((acc: Record, key: string, i: number) => { + if (acc[key] === undefined) acc[key] = {}; + + if (i === path.length - 1) { + acc[key] = { + ...acc[key], + ...value, + }; + } + + return acc[key]; + }, output); + }); + + return output; +}; diff --git a/yarn.lock b/yarn.lock index 49c3f4e..e4715c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -474,6 +474,11 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.55.0.tgz#b721d52060f369aa259cf97392403cb9ce892ec6" integrity sha512-qQfo2mxH5yVom1kacMtZZJFVdW+E70mqHMJvVg6WTLo+VBuQJ4TojZlfWBjK0ve5BdEeNAVxOsl/nvNMpJOaJA== +"@figma/rest-api-spec@^0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@figma/rest-api-spec/-/rest-api-spec-0.10.0.tgz#e605342c51149781603064ef0a0326632bdafdb9" + integrity sha512-QwAZ5iW/QmvX+HDD0ItK9TaE/BfhJitQWmi9JFZswGhthFO1EJcPYbFNlknOG+ZvtIeK693b0hDrVE0xpsZomQ== + "@humanwhocodes/config-array@^0.11.13": version "0.11.13" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.13.tgz#075dc9684f40a531d9b26b0822153c1e832ee297" @@ -6044,10 +6049,10 @@ type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== -type-fest@^4.8.3: - version "4.8.3" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.8.3.tgz#6db08d9f44d596cd953f83020c7c56310c368d1c" - integrity sha512-//BaTm14Q/gHBn09xlnKNqfI8t6bmdzx2DXYfPBNofN0WUybCEUDcbCWcTa0oF09lzLjZgPphXAsvRiMK0V6Bw== +type-fest@^4.12.0: + version "4.12.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.12.0.tgz#00ae70d02161b81ecd095158143c4bb8c879760d" + integrity sha512-5Y2/pp2wtJk8o08G0CMkuFPCO354FGwk/vbidxrdhRGZfd0tFnb4Qb8anp9XxXriwBgVPjdWbKpGl4J9lJY2jQ== typedarray@^0.0.6: version "0.0.6"