@seed-design/figma 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/lib/index.cjs +13918 -0
  2. package/lib/index.d.ts +63 -0
  3. package/lib/index.js +13916 -0
  4. package/package.json +44 -0
  5. package/src/color.ts +78 -0
  6. package/src/component/index.ts +1688 -0
  7. package/src/component/properties.ts +20 -0
  8. package/src/component/type-helper.ts +29 -0
  9. package/src/component/type.ts +318 -0
  10. package/src/data/__generated__/component-sets/action-button.d.ts +59 -0
  11. package/src/data/__generated__/component-sets/action-button.mjs +59 -0
  12. package/src/data/__generated__/component-sets/action-chip.d.ts +57 -0
  13. package/src/data/__generated__/component-sets/action-chip.mjs +57 -0
  14. package/src/data/__generated__/component-sets/action-sheet.d.ts +40 -0
  15. package/src/data/__generated__/component-sets/action-sheet.mjs +40 -0
  16. package/src/data/__generated__/component-sets/avatar-stack.d.ts +32 -0
  17. package/src/data/__generated__/component-sets/avatar-stack.mjs +32 -0
  18. package/src/data/__generated__/component-sets/avatar.d.ts +25 -0
  19. package/src/data/__generated__/component-sets/avatar.mjs +25 -0
  20. package/src/data/__generated__/component-sets/badge.d.ts +41 -0
  21. package/src/data/__generated__/component-sets/badge.mjs +41 -0
  22. package/src/data/__generated__/component-sets/bottom-navigation-global.d.ts +20 -0
  23. package/src/data/__generated__/component-sets/bottom-navigation-global.mjs +20 -0
  24. package/src/data/__generated__/component-sets/bottom-navigation-kr.d.ts +13 -0
  25. package/src/data/__generated__/component-sets/bottom-navigation-kr.mjs +13 -0
  26. package/src/data/__generated__/component-sets/bottom-sheet.d.ts +14 -0
  27. package/src/data/__generated__/component-sets/bottom-sheet.mjs +14 -0
  28. package/src/data/__generated__/component-sets/callout.d.ts +57 -0
  29. package/src/data/__generated__/component-sets/callout.mjs +57 -0
  30. package/src/data/__generated__/component-sets/checkbox.d.ts +44 -0
  31. package/src/data/__generated__/component-sets/checkbox.mjs +44 -0
  32. package/src/data/__generated__/component-sets/chip-tablist.d.ts +24 -0
  33. package/src/data/__generated__/component-sets/chip-tablist.mjs +24 -0
  34. package/src/data/__generated__/component-sets/control-chip.d.ts +60 -0
  35. package/src/data/__generated__/component-sets/control-chip.mjs +60 -0
  36. package/src/data/__generated__/component-sets/divider.d.ts +13 -0
  37. package/src/data/__generated__/component-sets/divider.mjs +13 -0
  38. package/src/data/__generated__/component-sets/error-state.d.ts +32 -0
  39. package/src/data/__generated__/component-sets/error-state.mjs +32 -0
  40. package/src/data/__generated__/component-sets/extended-action-sheet.d.ts +32 -0
  41. package/src/data/__generated__/component-sets/extended-action-sheet.mjs +32 -0
  42. package/src/data/__generated__/component-sets/extended-floating-action-button.d.ts +34 -0
  43. package/src/data/__generated__/component-sets/extended-floating-action-button.mjs +34 -0
  44. package/src/data/__generated__/component-sets/floating-action-button.d.ts +17 -0
  45. package/src/data/__generated__/component-sets/floating-action-button.mjs +17 -0
  46. package/src/data/__generated__/component-sets/help-bubble.d.ts +39 -0
  47. package/src/data/__generated__/component-sets/help-bubble.mjs +39 -0
  48. package/src/data/__generated__/component-sets/identity-placeholder.d.ts +13 -0
  49. package/src/data/__generated__/component-sets/identity-placeholder.mjs +13 -0
  50. package/src/data/__generated__/component-sets/index.d.ts +47 -0
  51. package/src/data/__generated__/component-sets/index.mjs +47 -0
  52. package/src/data/__generated__/component-sets/inline-banner.d.ts +40 -0
  53. package/src/data/__generated__/component-sets/inline-banner.mjs +40 -0
  54. package/src/data/__generated__/component-sets/main-tab-navigation-global.d.ts +26 -0
  55. package/src/data/__generated__/component-sets/main-tab-navigation-global.mjs +26 -0
  56. package/src/data/__generated__/component-sets/main-tab-navigation-kr.d.ts +23 -0
  57. package/src/data/__generated__/component-sets/main-tab-navigation-kr.mjs +23 -0
  58. package/src/data/__generated__/component-sets/manner-temp-badge.d.ts +17 -0
  59. package/src/data/__generated__/component-sets/manner-temp-badge.mjs +17 -0
  60. package/src/data/__generated__/component-sets/manner-temp-bar.d.ts +23 -0
  61. package/src/data/__generated__/component-sets/manner-temp-bar.mjs +23 -0
  62. package/src/data/__generated__/component-sets/manner-temp.d.ts +17 -0
  63. package/src/data/__generated__/component-sets/manner-temp.mjs +17 -0
  64. package/src/data/__generated__/component-sets/multiline-text-field.d.ts +68 -0
  65. package/src/data/__generated__/component-sets/multiline-text-field.mjs +68 -0
  66. package/src/data/__generated__/component-sets/progress-circle.d.ts +31 -0
  67. package/src/data/__generated__/component-sets/progress-circle.mjs +31 -0
  68. package/src/data/__generated__/component-sets/radio.d.ts +27 -0
  69. package/src/data/__generated__/component-sets/radio.mjs +27 -0
  70. package/src/data/__generated__/component-sets/range-slider.d.ts +31 -0
  71. package/src/data/__generated__/component-sets/range-slider.mjs +31 -0
  72. package/src/data/__generated__/component-sets/reaction-button.d.ts +39 -0
  73. package/src/data/__generated__/component-sets/reaction-button.mjs +39 -0
  74. package/src/data/__generated__/component-sets/segmented-control.d.ts +23 -0
  75. package/src/data/__generated__/component-sets/segmented-control.mjs +23 -0
  76. package/src/data/__generated__/component-sets/select-box.d.ts +31 -0
  77. package/src/data/__generated__/component-sets/select-box.mjs +31 -0
  78. package/src/data/__generated__/component-sets/skeleton.d.ts +15 -0
  79. package/src/data/__generated__/component-sets/skeleton.mjs +15 -0
  80. package/src/data/__generated__/component-sets/slider.d.ts +31 -0
  81. package/src/data/__generated__/component-sets/slider.mjs +31 -0
  82. package/src/data/__generated__/component-sets/snackbar.d.ts +23 -0
  83. package/src/data/__generated__/component-sets/snackbar.mjs +23 -0
  84. package/src/data/__generated__/component-sets/standard-navigation.d.ts +23 -0
  85. package/src/data/__generated__/component-sets/standard-navigation.mjs +23 -0
  86. package/src/data/__generated__/component-sets/switch.d.ts +25 -0
  87. package/src/data/__generated__/component-sets/switch.mjs +25 -0
  88. package/src/data/__generated__/component-sets/tablist.d.ts +29 -0
  89. package/src/data/__generated__/component-sets/tablist.mjs +29 -0
  90. package/src/data/__generated__/component-sets/template-bottom-fixed-bar.d.ts +42 -0
  91. package/src/data/__generated__/component-sets/template-bottom-fixed-bar.mjs +42 -0
  92. package/src/data/__generated__/component-sets/template-button-group.d.ts +51 -0
  93. package/src/data/__generated__/component-sets/template-button-group.mjs +51 -0
  94. package/src/data/__generated__/component-sets/template-chip-group.d.ts +26 -0
  95. package/src/data/__generated__/component-sets/template-chip-group.mjs +26 -0
  96. package/src/data/__generated__/component-sets/template-select-box-group.d.ts +24 -0
  97. package/src/data/__generated__/component-sets/template-select-box-group.mjs +24 -0
  98. package/src/data/__generated__/component-sets/template-top-navigation.d.ts +25 -0
  99. package/src/data/__generated__/component-sets/template-top-navigation.mjs +25 -0
  100. package/src/data/__generated__/component-sets/text-button.d.ts +45 -0
  101. package/src/data/__generated__/component-sets/text-button.mjs +45 -0
  102. package/src/data/__generated__/component-sets/text-field.d.ts +100 -0
  103. package/src/data/__generated__/component-sets/text-field.mjs +100 -0
  104. package/src/data/__generated__/component-sets/toggle-button.d.ts +50 -0
  105. package/src/data/__generated__/component-sets/toggle-button.mjs +50 -0
  106. package/src/data/icons.ts +2774 -0
  107. package/src/data/styles.ts +142 -0
  108. package/src/data/variables.ts +7772 -0
  109. package/src/generate-code.ts +213 -0
  110. package/src/icon.ts +46 -0
  111. package/src/index.ts +3 -0
  112. package/src/jsx.ts +104 -0
  113. package/src/layout.ts +289 -0
  114. package/src/node-util.ts +49 -0
  115. package/src/normalizer/from-plugin.ts +345 -0
  116. package/src/normalizer/from-rest.ts +178 -0
  117. package/src/normalizer/types.ts +94 -0
  118. package/src/sizing.ts +58 -0
  119. package/src/text.ts +20 -0
  120. package/src/util.ts +17 -0
  121. package/src/variable.ts +66 -0
@@ -0,0 +1,213 @@
1
+ import { camelCase } from "change-case";
2
+ import { createBackgroundProps, createBorderProps } from "./color";
3
+ import { componentHandlerMap, ignoredComponentKeys } from "./component";
4
+ import { iconRecord } from "./data/icons";
5
+ import { createIconTagNameFromKey, createMonochromeIconColorProps, isIconComponent } from "./icon";
6
+ import type { ElementNode } from "./jsx";
7
+ import { createElement, stringifyElement } from "./jsx";
8
+ import { createLayoutProps } from "./layout";
9
+ import type {
10
+ NormalizedComponentNode,
11
+ NormalizedFrameNode,
12
+ NormalizedInstanceNode,
13
+ NormalizedRectangleNode,
14
+ NormalizedSceneNode,
15
+ NormalizedTextNode,
16
+ } from "./normalizer/types";
17
+ import { createSizingProps } from "./sizing";
18
+ import { createTextProps } from "./text";
19
+ import { getColorVariableName, getLayoutVariableName, inferDimension } from "./variable";
20
+ import { FIGMA_TEXT_STYLES } from "./data/styles";
21
+ import { compactObject } from "./util";
22
+
23
+ export async function generateCode(selection: NormalizedSceneNode) {
24
+ async function handleFrameNode(
25
+ node: NormalizedFrameNode | NormalizedComponentNode | NormalizedInstanceNode,
26
+ ) {
27
+ const children = node.children;
28
+
29
+ const props = {
30
+ ...createLayoutProps(node),
31
+ ...createSizingProps(node),
32
+ ...createBackgroundProps(node),
33
+ ...createBorderProps(node),
34
+ };
35
+
36
+ if (
37
+ props.flexDirection === "row" &&
38
+ props.alignItems === "flexStart" &&
39
+ props.justifyContent === "flexStart" &&
40
+ props.flexWrap === "wrap"
41
+ ) {
42
+ const { flexDirection, flexWrap, alignItems, justifyContent, ...rest } = props;
43
+
44
+ return createElement("Inline", rest, await Promise.all(children.map(traverse)));
45
+ }
46
+
47
+ if (
48
+ props.flexDirection === "row" &&
49
+ props.justifyContent === "flexStart" &&
50
+ props.flexWrap === "nowrap"
51
+ ) {
52
+ const { flexDirection, flexWrap, justifyContent, ...rest } = props;
53
+
54
+ const childrenResult = await Promise.all(children.map(traverse));
55
+
56
+ return createElement(
57
+ "Columns",
58
+ rest,
59
+ childrenResult.map((child) => createElement("Column", {}, child)),
60
+ );
61
+ }
62
+
63
+ if (props.flexDirection === "column") {
64
+ const { flexDirection, ...rest } = props;
65
+
66
+ return createElement("Stack", rest, await Promise.all(children.map(traverse)));
67
+ }
68
+
69
+ return createElement("Flex", props, await Promise.all(children.map(traverse)));
70
+ }
71
+
72
+ function handleTextNode(node: NormalizedTextNode): ElementNode {
73
+ const maxLines =
74
+ node.style.textTruncation === "ENDING" ? (node.style.maxLines ?? undefined) : undefined;
75
+
76
+ if (node.fills.length > 1) {
77
+ throw new Error("Expected a single fill");
78
+ }
79
+
80
+ const onlyFill = node.fills.length === 1 ? node.fills[0] : null;
81
+ const fillBoundVariableId =
82
+ onlyFill && onlyFill.type === "SOLID" ? (onlyFill.boundVariables?.color?.id ?? null) : null;
83
+ const color = fillBoundVariableId ? getColorVariableName(fillBoundVariableId) : undefined;
84
+
85
+ const style = FIGMA_TEXT_STYLES.find((s) => s.key === node.textStyleKey);
86
+
87
+ if (style) {
88
+ const styleNameSlugs = style.name.split("/");
89
+ const styleName = styleNameSlugs[styleNameSlugs.length - 1]!;
90
+ return createElement(
91
+ "Text",
92
+ compactObject({
93
+ textStyle: camelCase(styleName, { mergeAmbiguousCharacters: true }),
94
+ maxLines,
95
+ color,
96
+ }),
97
+ node.characters.replace(/\n/g, "<br />"),
98
+ color ? "" : "color 프로퍼티는 반영되지 않았습니다.",
99
+ );
100
+ }
101
+
102
+ const { fontSize, fontWeight, lineHeight } = createTextProps(node.boundVariables);
103
+
104
+ return createElement(
105
+ "Text",
106
+ compactObject({
107
+ fontSize,
108
+ fontWeight,
109
+ lineHeight,
110
+ color,
111
+ }),
112
+ node.characters.replace(/\n/g, "<br />"),
113
+ );
114
+ }
115
+
116
+ async function handleRectangleNode(node: NormalizedRectangleNode) {
117
+ return createElement(
118
+ "Box",
119
+ { ...createSizingProps(node), background: "palette.gray200" },
120
+ undefined,
121
+ "Rectangle Node Placeholder",
122
+ );
123
+ }
124
+
125
+ async function handleComponentNode(node: NormalizedComponentNode) {
126
+ return await handleFrameNode(node);
127
+ }
128
+
129
+ async function handleInstanceNode(node: NormalizedInstanceNode) {
130
+ const { componentKey, componentSetKey } = node;
131
+
132
+ if (isIconComponent(componentKey)) {
133
+ const iconElement = createElement(createIconTagNameFromKey(componentKey));
134
+
135
+ switch (iconRecord[componentKey]?.type) {
136
+ case "monochrome":
137
+ return createElement("Icon", {
138
+ size:
139
+ getLayoutVariableName(node.boundVariables?.size?.x?.id) ??
140
+ inferDimension(node.absoluteBoundingBox?.width ?? 0),
141
+ ...createMonochromeIconColorProps(node),
142
+ svg: iconElement,
143
+ });
144
+ case "multicolor":
145
+ return iconElement;
146
+ default:
147
+ return createElement("Icon", {
148
+ size:
149
+ getLayoutVariableName(node.boundVariables?.size?.x?.id) ??
150
+ inferDimension(node.absoluteBoundingBox?.width ?? 0),
151
+ svg: iconElement,
152
+ ...createMonochromeIconColorProps(node),
153
+ });
154
+ }
155
+ }
156
+
157
+ if (ignoredComponentKeys.has(componentSetKey ?? componentKey)) {
158
+ return;
159
+ }
160
+
161
+ const componentData = componentSetKey
162
+ ? componentHandlerMap.get(componentSetKey)
163
+ : componentHandlerMap.get(componentKey);
164
+
165
+ if (componentData) {
166
+ return componentData.codegen(node);
167
+ }
168
+
169
+ // if (node.id === selection.id) {
170
+ return await handleFrameNode(node);
171
+ // }
172
+
173
+ // const mainComponent = node.mainComponent;
174
+
175
+ // return createElement(
176
+ // mainComponent.parent?.type === "COMPONENT_SET"
177
+ // ? mainComponent.parent.name
178
+ // : mainComponent.name,
179
+ // Object.fromEntries(
180
+ // Object.entries(node.componentProperties)
181
+ // .filter(([_, props]) => props.type === "VARIANT" || props.type === "TEXT")
182
+ // .map(([key, props]) => [camelCase(key), camelCase(props.value as string)]),
183
+ // ),
184
+ // undefined,
185
+ // "Custom Component",
186
+ // );
187
+ }
188
+
189
+ async function traverse(node: NormalizedSceneNode): Promise<ElementNode | undefined> {
190
+ if ("visible" in node && !node.visible) {
191
+ return;
192
+ }
193
+
194
+ if (node.type === "FRAME") return await handleFrameNode(node);
195
+ if (node.type === "TEXT") return handleTextNode(node);
196
+ if (node.type === "RECTANGLE") return await handleRectangleNode(node);
197
+ if (node.type === "COMPONENT") return await handleComponentNode(node);
198
+ if (node.type === "INSTANCE") return await handleInstanceNode(node);
199
+
200
+ return;
201
+ }
202
+
203
+ try {
204
+ const rootEl = await traverse(selection);
205
+ if (!rootEl) {
206
+ return "";
207
+ }
208
+ return stringifyElement(rootEl);
209
+ } catch (e) {
210
+ console.error(e);
211
+ return "";
212
+ }
213
+ }
package/src/icon.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { pascalCase } from "change-case";
2
+
3
+ import { createColorProps } from "./color";
4
+ import { iconRecord } from "./data/icons";
5
+ import type { NormalizedInstanceNode } from "./normalizer/types";
6
+
7
+ export function isIconComponent(componentKey: string) {
8
+ return !!iconRecord[componentKey];
9
+ }
10
+
11
+ export function createIconTagNameFromKey(key?: string) {
12
+ if (!key) {
13
+ return "UnknownIcon";
14
+ }
15
+
16
+ const iconData = iconRecord[key];
17
+ if (!iconData) {
18
+ throw new Error(`Icon not found: ${key}`);
19
+ }
20
+
21
+ const { name, weight } = iconData;
22
+
23
+ return pascalCase(`${name}${weight ? weight : ""}`);
24
+ }
25
+
26
+ export function createMonochromeIconColorProps(node: NormalizedInstanceNode) {
27
+ if (node.children.length === 0) {
28
+ throw new Error("Icon node has no children");
29
+ }
30
+
31
+ const icons = node.children.filter(
32
+ (child) => child.type === "VECTOR" || child.type === "BOOLEAN_OPERATION",
33
+ );
34
+
35
+ const colorProps = icons.map(createColorProps);
36
+
37
+ const fills = new Set(
38
+ colorProps.map((props) => props.color).filter((color) => color !== undefined),
39
+ );
40
+
41
+ if (fills.size > 1) {
42
+ throw new Error(`Children of the icon node ${node.name} has multiple colors`);
43
+ }
44
+
45
+ return { color: fills.values().next().value };
46
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { generateCode } from "./generate-code";
2
+ export { createPluginNormalizer } from "./normalizer/from-plugin";
3
+ export { createRestNormalizer } from "./normalizer/from-rest";
package/src/jsx.ts ADDED
@@ -0,0 +1,104 @@
1
+ import { ensureArray, exists } from "./util";
2
+
3
+ export interface ElementNode {
4
+ __IS_JSX_ELEMENT_NODE: true;
5
+ tag: string;
6
+ props: Record<string, string | number | boolean | ElementNode | object | undefined>;
7
+ children: (ElementNode | string)[];
8
+ comment?: string;
9
+ }
10
+
11
+ export function createElement(
12
+ tag: string,
13
+ props: Record<string, string | number | boolean | object | undefined> = {},
14
+ children?: ElementNode | string | undefined | (ElementNode | string | undefined)[],
15
+ comment?: string,
16
+ ): ElementNode {
17
+ return {
18
+ __IS_JSX_ELEMENT_NODE: true,
19
+ tag,
20
+ props,
21
+ children: ensureArray(children).filter(exists),
22
+ comment,
23
+ };
24
+ }
25
+
26
+ export function isElement(node: unknown): node is ElementNode {
27
+ return (
28
+ typeof node === "object" &&
29
+ node != null &&
30
+ "__IS_JSX_ELEMENT_NODE" in node &&
31
+ node.__IS_JSX_ELEMENT_NODE === true
32
+ );
33
+ }
34
+
35
+ export function stringifyElement(element: ElementNode) {
36
+ function recursive(node: ElementNode | string, depth: number): string {
37
+ if (typeof node === "string") {
38
+ return node;
39
+ }
40
+
41
+ const { tag, props, children, comment } = node;
42
+ const propEntries = Object.entries(props);
43
+ const propFragments = propEntries
44
+ .map(([key, value]) => {
45
+ if (typeof value === "string") {
46
+ if (value.includes("\n")) {
47
+ return `${key}={\"${value.replace("\n", "\\n")}\"}`;
48
+ }
49
+
50
+ return `${key}="${value}"`;
51
+ }
52
+
53
+ if (typeof value === "number") {
54
+ return `${key}={${value}}`;
55
+ }
56
+
57
+ if (typeof value === "boolean") {
58
+ return `${key}={${value}}`;
59
+ }
60
+
61
+ if (isElement(value)) {
62
+ return `${key}={${recursive(value, depth + 1)}}`;
63
+ }
64
+
65
+ if (typeof value === "object") {
66
+ return `${key}={${JSON.stringify(value)}}`;
67
+ }
68
+
69
+ if (typeof value === "undefined") {
70
+ return undefined;
71
+ }
72
+ })
73
+ .filter(exists);
74
+
75
+ const oneLiner = propFragments.join(" ");
76
+ const propsString =
77
+ propEntries.length === 0
78
+ ? ""
79
+ : ` ${
80
+ oneLiner.length < 80
81
+ ? oneLiner
82
+ : `\n${" ".repeat(depth + 1)}${propFragments.join(
83
+ `\n${" ".repeat(depth + 1)}`,
84
+ )}\n${" ".repeat(depth)}`
85
+ }`;
86
+
87
+ if (children == null || children.length === 0) {
88
+ return `<${tag}${propsString} />${comment ? `{/* ${comment} */}` : ""}`;
89
+ }
90
+
91
+ const result = [
92
+ `<${tag}${propsString}>`,
93
+ ...ensureArray(children)
94
+ .filter(exists)
95
+ .map((child) => recursive(child, depth + 1))
96
+ .map((str) => " ".repeat(depth + 1) + str),
97
+ `${" ".repeat(depth)}</${tag}>${comment ? `{/* ${comment} */}` : ""}`,
98
+ ].join("\n");
99
+
100
+ return result;
101
+ }
102
+
103
+ return recursive(element, 0);
104
+ }
package/src/layout.ts ADDED
@@ -0,0 +1,289 @@
1
+ import type {
2
+ NormalizedComponentNode,
3
+ NormalizedFrameNode,
4
+ NormalizedInstanceNode,
5
+ NormalizedSceneNode,
6
+ } from "./normalizer/types";
7
+ import { getLayoutVariableName, inferDimension, inferRadius } from "./variable";
8
+
9
+ interface FigmaLayoutProps {
10
+ layoutMode?: NormalizedFrameNode["layoutMode"];
11
+ layoutWrap?: NormalizedFrameNode["layoutWrap"];
12
+ paddingLeft?: NormalizedFrameNode["paddingLeft"];
13
+ paddingRight?: NormalizedFrameNode["paddingRight"];
14
+ paddingTop?: NormalizedFrameNode["paddingTop"];
15
+ paddingBottom?: NormalizedFrameNode["paddingBottom"];
16
+ primaryAxisAlignItems?: NormalizedFrameNode["primaryAxisAlignItems"];
17
+ counterAxisAlignItems?: NormalizedFrameNode["counterAxisAlignItems"];
18
+ primaryAxisSizingMode?: NormalizedFrameNode["primaryAxisSizingMode"];
19
+ counterAxisSizingMode?: NormalizedFrameNode["counterAxisSizingMode"];
20
+ layoutGrow?: NormalizedFrameNode["layoutGrow"];
21
+ layoutAlign?: NormalizedFrameNode["layoutAlign"];
22
+ itemSpacing?: NormalizedFrameNode["itemSpacing"];
23
+ counterAxisSpacing?: NormalizedFrameNode["counterAxisSpacing"];
24
+ boundVariables?: NormalizedFrameNode["boundVariables"];
25
+ cornerRadius?: NormalizedFrameNode["cornerRadius"];
26
+ rectangleCornerRadii?: NormalizedFrameNode["rectangleCornerRadii"];
27
+ children: NormalizedSceneNode[];
28
+ }
29
+
30
+ type LayoutPropHandler = (props: FigmaLayoutProps) => string | number | boolean | undefined;
31
+
32
+ const layoutPropHandlers = {
33
+ flexDirection: ({ layoutMode }) => (layoutMode === "HORIZONTAL" ? "row" : "column"),
34
+ justifyContent: ({ primaryAxisAlignItems }) => {
35
+ switch (primaryAxisAlignItems) {
36
+ case "MIN":
37
+ return "flexStart";
38
+ case "CENTER":
39
+ return "center";
40
+ case "MAX":
41
+ return "flexEnd";
42
+ case "SPACE_BETWEEN":
43
+ return "spaceBetween";
44
+ }
45
+ },
46
+ alignItems: ({ counterAxisAlignItems, children }) => {
47
+ const isStretch = children.every((child) => {
48
+ if (!("layoutAlign" in child)) {
49
+ return false;
50
+ }
51
+
52
+ return child.layoutAlign === "STRETCH";
53
+ });
54
+
55
+ if (isStretch) {
56
+ return "stretch";
57
+ }
58
+
59
+ switch (counterAxisAlignItems) {
60
+ case "MIN":
61
+ return "flexStart";
62
+ case "CENTER":
63
+ return "center";
64
+ case "MAX":
65
+ return "flexEnd";
66
+ case "BASELINE":
67
+ return "baseline";
68
+ }
69
+ },
70
+ flexWrap: ({ layoutWrap }) => (layoutWrap === "WRAP" ? "wrap" : "nowrap"),
71
+ flexGrow: ({ layoutGrow }) => layoutGrow,
72
+ alignSelf: ({ layoutAlign }) => {
73
+ switch (layoutAlign) {
74
+ case "STRETCH":
75
+ return "stretch";
76
+ case "MIN":
77
+ return "flexStart";
78
+ case "CENTER":
79
+ return "center";
80
+ case "MAX":
81
+ return "flexEnd";
82
+ }
83
+ },
84
+ gap: ({ itemSpacing, boundVariables, primaryAxisAlignItems, children }) =>
85
+ children.length <= 1
86
+ ? 0
87
+ : primaryAxisAlignItems === "SPACE_BETWEEN"
88
+ ? 0
89
+ : boundVariables?.itemSpacing
90
+ ? getLayoutVariableName(boundVariables.itemSpacing.id)
91
+ : inferDimension(itemSpacing ?? 0),
92
+ paddingTop: ({ paddingTop, boundVariables }) =>
93
+ boundVariables?.paddingTop
94
+ ? getLayoutVariableName(boundVariables.paddingTop.id)
95
+ : inferDimension(paddingTop ?? 0),
96
+ paddingBottom: ({ paddingBottom, boundVariables }) =>
97
+ boundVariables?.paddingBottom
98
+ ? getLayoutVariableName(boundVariables.paddingBottom.id)
99
+ : inferDimension(paddingBottom ?? 0),
100
+ paddingLeft: ({ paddingLeft, boundVariables }) =>
101
+ boundVariables?.paddingLeft
102
+ ? getLayoutVariableName(boundVariables.paddingLeft.id)
103
+ : inferDimension(paddingLeft ?? 0),
104
+ paddingRight: ({ paddingRight, boundVariables }) =>
105
+ boundVariables?.paddingRight
106
+ ? getLayoutVariableName(boundVariables.paddingRight.id)
107
+ : inferDimension(paddingRight ?? 0),
108
+ borderRadius: ({ cornerRadius, boundVariables }) => {
109
+ // If all corner radii are the same, use the first one
110
+ if (
111
+ cornerRadius &&
112
+ boundVariables?.bottomLeftRadius === boundVariables?.bottomRightRadius &&
113
+ boundVariables?.bottomLeftRadius === boundVariables?.topLeftRadius &&
114
+ boundVariables?.bottomLeftRadius === boundVariables?.topRightRadius
115
+ ) {
116
+ return boundVariables?.bottomLeftRadius
117
+ ? getLayoutVariableName(boundVariables.bottomLeftRadius.id)
118
+ : inferRadius(cornerRadius ?? 0);
119
+ }
120
+
121
+ // TODO: handle individual corner radii
122
+ return undefined;
123
+ },
124
+ borderTopLeftRadius: ({ rectangleCornerRadii, boundVariables }) =>
125
+ boundVariables?.topLeftRadius
126
+ ? getLayoutVariableName(boundVariables.topLeftRadius.id)
127
+ : inferRadius(rectangleCornerRadii?.[0] ?? 0),
128
+ borderTopRightRadius: ({ rectangleCornerRadii, boundVariables }) =>
129
+ boundVariables?.topRightRadius
130
+ ? getLayoutVariableName(boundVariables.topRightRadius.id)
131
+ : inferRadius(rectangleCornerRadii?.[1] ?? 0),
132
+ borderBottomLeftRadius: ({ rectangleCornerRadii, boundVariables }) =>
133
+ boundVariables?.bottomLeftRadius
134
+ ? getLayoutVariableName(boundVariables.bottomLeftRadius.id)
135
+ : inferRadius(rectangleCornerRadii?.[2] ?? 0),
136
+ borderBottomRightRadius: ({ rectangleCornerRadii, boundVariables }) =>
137
+ boundVariables?.bottomRightRadius
138
+ ? getLayoutVariableName(boundVariables.bottomRightRadius.id)
139
+ : inferRadius(rectangleCornerRadii?.[3] ?? 0),
140
+ } satisfies Record<string, LayoutPropHandler>;
141
+
142
+ type LayoutProps = keyof typeof layoutPropHandlers;
143
+
144
+ type LayoutShorthandHandler = (props: Record<LayoutProps, string | number | boolean | undefined>) =>
145
+ | {
146
+ value: string | number | boolean | undefined;
147
+ exclude: LayoutProps[];
148
+ }
149
+ | undefined;
150
+
151
+ const layoutShorthandHandlers = {
152
+ paddingX: ({ paddingLeft, paddingRight, paddingTop, paddingBottom }) => {
153
+ if (
154
+ paddingLeft === paddingRight &&
155
+ paddingTop === paddingBottom &&
156
+ paddingLeft === paddingTop
157
+ ) {
158
+ return undefined;
159
+ }
160
+ if (paddingLeft === paddingRight) {
161
+ const value =
162
+ paddingLeft === "globalGutter" || paddingLeft === "betweenChips"
163
+ ? `spacingX.${paddingLeft}`
164
+ : paddingLeft;
165
+ return {
166
+ value,
167
+ exclude: ["paddingLeft", "paddingRight"],
168
+ };
169
+ }
170
+ return undefined;
171
+ },
172
+ paddingY: ({ paddingLeft, paddingRight, paddingTop, paddingBottom }) => {
173
+ if (
174
+ paddingLeft === paddingRight &&
175
+ paddingTop === paddingBottom &&
176
+ paddingLeft === paddingTop
177
+ ) {
178
+ return undefined;
179
+ }
180
+ if (paddingTop === paddingBottom) {
181
+ return {
182
+ value: paddingTop,
183
+ exclude: ["paddingTop", "paddingBottom"],
184
+ };
185
+ }
186
+ return undefined;
187
+ },
188
+ padding: ({ paddingLeft, paddingRight, paddingTop, paddingBottom }) => {
189
+ if (
190
+ paddingLeft === paddingRight &&
191
+ paddingTop === paddingBottom &&
192
+ paddingLeft === paddingTop
193
+ ) {
194
+ return {
195
+ value: paddingLeft,
196
+ exclude: ["paddingLeft", "paddingRight", "paddingTop", "paddingBottom"],
197
+ };
198
+ }
199
+ return undefined;
200
+ },
201
+ } satisfies Record<string, LayoutShorthandHandler>;
202
+
203
+ type LayoutShorthandProps = keyof typeof layoutShorthandHandlers;
204
+
205
+ const layoutPropDefaults: Record<string, string | number | boolean> = {
206
+ flexDirection: "row",
207
+ justifyContent: "flexStart",
208
+ alignItems: "stretch",
209
+ flexWrap: "nowrap",
210
+ flexGrow: 0,
211
+ alignSelf: "auto",
212
+ gap: 0,
213
+ padding: 0,
214
+ paddingX: 0,
215
+ paddingY: 0,
216
+ paddingBottom: 0,
217
+ paddingLeft: 0,
218
+ paddingRight: 0,
219
+ paddingTop: 0,
220
+ borderRadius: 0,
221
+ borderTopLeftRadius: 0,
222
+ borderTopRightRadius: 0,
223
+ borderBottomLeftRadius: 0,
224
+ borderBottomRightRadius: 0,
225
+ } satisfies Record<LayoutProps | LayoutShorthandProps, string | number | boolean>;
226
+
227
+ type FrameLikeNode = NormalizedFrameNode | NormalizedComponentNode | NormalizedInstanceNode;
228
+
229
+ export function createLayoutProps(
230
+ node: FrameLikeNode,
231
+ ): Record<LayoutProps | LayoutShorthandProps, string | number | boolean> {
232
+ const boundVariables = node.boundVariables;
233
+ const children = node.children;
234
+
235
+ const autoLayoutProperties = {
236
+ layoutMode: node.layoutMode,
237
+ layoutWrap: node.layoutWrap,
238
+ paddingLeft: node.paddingLeft,
239
+ paddingRight: node.paddingRight,
240
+ paddingTop: node.paddingTop,
241
+ paddingBottom: node.paddingBottom,
242
+ primaryAxisAlignItems: node.primaryAxisAlignItems,
243
+ counterAxisAlignItems: node.counterAxisAlignItems,
244
+ primaryAxisSizingMode: node.primaryAxisSizingMode,
245
+ counterAxisSizingMode: node.counterAxisSizingMode,
246
+ layoutGrow: node.layoutGrow,
247
+ layoutAlign: node.layoutAlign,
248
+ itemSpacing: node.itemSpacing,
249
+ counterAxisSpacing: node.counterAxisSpacing,
250
+ };
251
+
252
+ const radiusProperties = {
253
+ cornerRadius: node.cornerRadius,
254
+ topLeftRadius: node.rectangleCornerRadii?.[0],
255
+ topRightRadius: node.rectangleCornerRadii?.[1],
256
+ bottomRightRadius: node.rectangleCornerRadii?.[2],
257
+ bottomLeftRadius: node.rectangleCornerRadii?.[3],
258
+ };
259
+
260
+ const result: Record<string, string | number | boolean> = {};
261
+
262
+ for (const [prop, handler] of Object.entries(layoutPropHandlers)) {
263
+ const value = handler({
264
+ ...autoLayoutProperties,
265
+ ...radiusProperties,
266
+ boundVariables,
267
+ children,
268
+ });
269
+ if (value !== undefined && value !== layoutPropDefaults[prop]) {
270
+ result[prop] = value;
271
+ }
272
+ }
273
+
274
+ for (const [prop, handler] of Object.entries(layoutShorthandHandlers)) {
275
+ const shorthandResult = handler(result);
276
+ if (shorthandResult === undefined) {
277
+ continue;
278
+ }
279
+ const { value, exclude } = shorthandResult;
280
+ if (value !== undefined && value !== layoutPropDefaults[prop]) {
281
+ result[prop] = value;
282
+ for (const excludedProp of exclude) {
283
+ delete result[excludedProp];
284
+ }
285
+ }
286
+ }
287
+
288
+ return result;
289
+ }
@@ -0,0 +1,49 @@
1
+ import type { NormalizedInstanceNode, NormalizedSceneNode } from "./normalizer/types";
2
+
3
+ export function traverseNode(
4
+ node: NormalizedSceneNode,
5
+ callback: (node: NormalizedSceneNode) => void,
6
+ ) {
7
+ if ("children" in node) {
8
+ node.children.forEach((child) => traverseNode(child, callback));
9
+ } else {
10
+ callback(node);
11
+ }
12
+ }
13
+
14
+ export function findOne(
15
+ node: NormalizedSceneNode,
16
+ callback: (node: NormalizedSceneNode) => boolean,
17
+ ) {
18
+ let result: NormalizedSceneNode | undefined;
19
+
20
+ traverseNode(node, (n) => {
21
+ if (callback(n)) {
22
+ result = n;
23
+ }
24
+ });
25
+
26
+ return result;
27
+ }
28
+
29
+ export function findAll(
30
+ node: NormalizedSceneNode,
31
+ callback: (node: NormalizedSceneNode) => boolean,
32
+ ) {
33
+ const result: NormalizedSceneNode[] = [];
34
+
35
+ traverseNode(node, (n) => {
36
+ if (callback(n)) {
37
+ result.push(n);
38
+ }
39
+ });
40
+
41
+ return result;
42
+ }
43
+
44
+ export function findAllInstances<T>({ node, key }: { node: NormalizedSceneNode; key: string }) {
45
+ return findAll(
46
+ node,
47
+ (n) => n.type === "INSTANCE" && (n.componentKey === key || n.componentSetKey === key),
48
+ ) as (NormalizedInstanceNode & { componentProperties: T })[];
49
+ }