inkhouse 0.1.0-beta.0
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.
- package/README.md +201 -0
- package/bin/inkhouse.mjs +171 -0
- package/code.js +11802 -0
- package/manifest.json +30 -0
- package/package.json +45 -0
- package/scanner/blob-placement-regression.ts +132 -0
- package/scanner/class-collector.ts +69 -0
- package/scanner/cli.ts +336 -0
- package/scanner/component-scanner.ts +2876 -0
- package/scanner/css-patch-regression.ts +112 -0
- package/scanner/css-token-reader-regression.ts +92 -0
- package/scanner/css-token-reader.ts +477 -0
- package/scanner/font-style-resolver-regression.ts +32 -0
- package/scanner/index.ts +9 -0
- package/scanner/radial-gradient-regression.ts +53 -0
- package/scanner/style-map.ts +145 -0
- package/scanner/tailwind-parser.ts +644 -0
- package/scanner/transform-math-regression.ts +42 -0
- package/scanner/types.ts +298 -0
- package/src/blob-placement.ts +111 -0
- package/src/change-detection.ts +204 -0
- package/src/class-utils.ts +105 -0
- package/src/clip-path-decorative.ts +194 -0
- package/src/color-resolver.ts +98 -0
- package/src/colors.ts +196 -0
- package/src/component-defs.ts +54 -0
- package/src/component-gen.ts +561 -0
- package/src/component-lookup.ts +82 -0
- package/src/config.ts +115 -0
- package/src/design-system.ts +59 -0
- package/src/dev-server.ts +173 -0
- package/src/figma-globals.d.ts +3 -0
- package/src/font-style-resolver.ts +171 -0
- package/src/github.ts +1465 -0
- package/src/icon-builder.ts +607 -0
- package/src/image-cache.ts +22 -0
- package/src/inline-text.ts +271 -0
- package/src/layout-parser.ts +667 -0
- package/src/layout-utils.ts +155 -0
- package/src/main.ts +687 -0
- package/src/node-ir.ts +595 -0
- package/src/pack-provider.ts +148 -0
- package/src/packs.ts +126 -0
- package/src/radial-gradient.ts +84 -0
- package/src/render-context.ts +138 -0
- package/src/responsive-analyzer.ts +139 -0
- package/src/state-analyzer.ts +143 -0
- package/src/story-builder.ts +1706 -0
- package/src/story-layout.ts +38 -0
- package/src/tailwind.ts +2379 -0
- package/src/text-builder.ts +116 -0
- package/src/text-line.ts +42 -0
- package/src/token-source.ts +43 -0
- package/src/tokens.ts +717 -0
- package/src/transform-math.ts +44 -0
- package/src/ui-builder.ts +1996 -0
- package/src/utility-resolver.ts +125 -0
- package/src/variables.ts +1042 -0
- package/src/width-solver.ts +466 -0
- package/templates/patch-tokens-route.ts +165 -0
- package/templates/scan-components-route.ts +57 -0
- package/ui.html +1222 -0
|
@@ -0,0 +1,1996 @@
|
|
|
1
|
+
import { parseColor, type RGB } from './colors';
|
|
2
|
+
import { pxFromSizeToken } from './variables';
|
|
3
|
+
import { applyTailwindStylesToFrame, applyBorderWidthUtilities, applyAbsoluteIfPossible, applyFullWidthIfPossible, markFullWidthNode, applyDeferredBottomPositioning, applyDeferredCenterYPositioning } from './tailwind';
|
|
4
|
+
import { createCompoundComponent } from './component-gen';
|
|
5
|
+
import { LayoutParser } from './layout-parser';
|
|
6
|
+
import { extractTextColorToken, resolveTextColorFallback, resolveTextColorValue } from './color-resolver';
|
|
7
|
+
import { TAG_TYPOGRAPHY, createTextNode, getLineHeightFromClasses, getNodeTextStyle, getTextAlignFromClasses } from './text-builder';
|
|
8
|
+
import { defaultGridWidth } from './story-layout';
|
|
9
|
+
import { getCompoundClasses, mergeClasses } from './class-utils';
|
|
10
|
+
import { getClassesForBreakpoint } from './responsive-analyzer';
|
|
11
|
+
import {
|
|
12
|
+
type StoryBuilderContext,
|
|
13
|
+
createCVAStoryInstance,
|
|
14
|
+
createStateStoryInstance,
|
|
15
|
+
createUIComponents as createUIComponentsFromStory,
|
|
16
|
+
} from './story-builder';
|
|
17
|
+
import { applyClipBehavior, shouldClipContent, applyGridColumnsWithReflow, maybeWrapMxAuto, resolveGridWidthOverride } from './layout-utils';
|
|
18
|
+
import { getComponentDefByName, getIconRegistryEntry, getReactIconKey } from './component-lookup';
|
|
19
|
+
import {
|
|
20
|
+
type RenderContext,
|
|
21
|
+
hasWidthHintInClasses,
|
|
22
|
+
propsContainWidthHint,
|
|
23
|
+
hasExplicitHeight,
|
|
24
|
+
} from './render-context';
|
|
25
|
+
import { createInlineTextNode, isInlineTextNode } from './inline-text';
|
|
26
|
+
import { maybeExpandTextLineComponent } from './text-line';
|
|
27
|
+
import {
|
|
28
|
+
extractGridColumns,
|
|
29
|
+
getBaseClass,
|
|
30
|
+
getNodeLayoutComputed,
|
|
31
|
+
shouldSkipFullWidthForClasses,
|
|
32
|
+
shouldStretchToParentWidth,
|
|
33
|
+
solveLayoutWidths,
|
|
34
|
+
} from './width-solver';
|
|
35
|
+
import { parseUtilityClass } from './utility-resolver';
|
|
36
|
+
import { getImageHash, getSvgString } from './image-cache';
|
|
37
|
+
import { buildDecorativeClipPathNode, parseClipPathFromStyle } from './clip-path-decorative';
|
|
38
|
+
import {
|
|
39
|
+
type JsxNode,
|
|
40
|
+
type JsxElement,
|
|
41
|
+
type JsxText,
|
|
42
|
+
type NodeIR,
|
|
43
|
+
type NodeIRElement,
|
|
44
|
+
type NodeIRHelpers,
|
|
45
|
+
resolveNodeIR,
|
|
46
|
+
applyNodeTransforms,
|
|
47
|
+
isElementLikeNode,
|
|
48
|
+
} from './node-ir';
|
|
49
|
+
// Icon handling extracted to separate module
|
|
50
|
+
import {
|
|
51
|
+
ICON_PATHS,
|
|
52
|
+
createIcon,
|
|
53
|
+
createIconFromSvg,
|
|
54
|
+
nodeIrToSvg,
|
|
55
|
+
resizeSvgNodeTo,
|
|
56
|
+
wrapIconNode,
|
|
57
|
+
resolveIconSizeFromClasses,
|
|
58
|
+
getVectorPaintUsage,
|
|
59
|
+
} from './icon-builder';
|
|
60
|
+
|
|
61
|
+
declare const figma: any;
|
|
62
|
+
|
|
63
|
+
const RESPONSIVE_BREAKPOINT_ORDER = ['sm', 'md', 'lg', 'xl', '2xl'] as const;
|
|
64
|
+
const RESPONSIVE_BREAKPOINT_MIN_WIDTH: Record<typeof RESPONSIVE_BREAKPOINT_ORDER[number], number> = {
|
|
65
|
+
sm: 640,
|
|
66
|
+
md: 768,
|
|
67
|
+
lg: 1024,
|
|
68
|
+
xl: 1280,
|
|
69
|
+
'2xl': 1536,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function resolveBreakpointIndexForWidth(width?: number): number {
|
|
73
|
+
if (!Number.isFinite(width) || (width as number) <= 0) return -1;
|
|
74
|
+
let idx = -1;
|
|
75
|
+
for (let i = 0; i < RESPONSIVE_BREAKPOINT_ORDER.length; i++) {
|
|
76
|
+
const bp = RESPONSIVE_BREAKPOINT_ORDER[i];
|
|
77
|
+
if ((width as number) >= RESPONSIVE_BREAKPOINT_MIN_WIDTH[bp]) {
|
|
78
|
+
idx = i;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return idx;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isAccordionRootTag(tagName: string): boolean {
|
|
85
|
+
return tagName === 'Accordion' || tagName === 'AccordionPrimitive.Root';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isAccordionItemTag(tagName: string): boolean {
|
|
89
|
+
return tagName === 'AccordionItem' || tagName === 'AccordionPrimitive.Item';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isAccordionContentTag(tagName: string): boolean {
|
|
93
|
+
return tagName === 'AccordionContent' || tagName === 'AccordionPrimitive.Content';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isAccordionHeaderTag(tagName: string): boolean {
|
|
97
|
+
return tagName === 'AccordionHeader' || tagName === 'AccordionPrimitive.Header';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isAccordionChevronTag(tagName: string): boolean {
|
|
101
|
+
return tagName === 'ChevronDownIcon' || tagName === 'ChevronDown';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function normalizeAccordionDefaultValue(value: unknown): string | null {
|
|
105
|
+
if (typeof value !== 'string') return null;
|
|
106
|
+
const trimmed = value.trim();
|
|
107
|
+
if (!trimmed || trimmed === 'defaultValue' || trimmed === 'undefined' || trimmed === 'null') {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
return trimmed;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resolveAccordionItemValue(value: unknown, itemIndex?: number): string | null {
|
|
114
|
+
if (typeof value === 'string') {
|
|
115
|
+
const trimmed = value.trim();
|
|
116
|
+
if (trimmed) {
|
|
117
|
+
if (trimmed.includes('${i}')) {
|
|
118
|
+
return Number.isFinite(itemIndex) ? `item-${itemIndex}` : null;
|
|
119
|
+
}
|
|
120
|
+
return trimmed;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return Number.isFinite(itemIndex) ? `item-${itemIndex}` : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function createPerChildRenderContext(
|
|
127
|
+
baseContext: RenderContext,
|
|
128
|
+
parentNode: NodeIR,
|
|
129
|
+
child: NodeIR,
|
|
130
|
+
accordionItemOrdinal: { value: number }
|
|
131
|
+
): RenderContext {
|
|
132
|
+
const childContext: RenderContext = { ...baseContext };
|
|
133
|
+
if (
|
|
134
|
+
(parentNode.kind === 'element' || parentNode.kind === 'component') &&
|
|
135
|
+
isAccordionRootTag(parentNode.tagName) &&
|
|
136
|
+
(child.kind === 'element' || child.kind === 'component') &&
|
|
137
|
+
isAccordionItemTag(child.tagName)
|
|
138
|
+
) {
|
|
139
|
+
childContext.accordionItemIndex = accordionItemOrdinal.value++;
|
|
140
|
+
}
|
|
141
|
+
return childContext;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function getRenderableChildren(node: NodeIR, classes: string[]): NodeIR[] {
|
|
145
|
+
if (node.kind !== 'element' && node.kind !== 'component') return [];
|
|
146
|
+
const children = node.children.slice();
|
|
147
|
+
if (node.kind !== 'element' || node.tagLower !== 'table') return children;
|
|
148
|
+
if (!classes.includes('caption-bottom')) return children;
|
|
149
|
+
|
|
150
|
+
const captions: NodeIR[] = [];
|
|
151
|
+
const others: NodeIR[] = [];
|
|
152
|
+
for (const child of children) {
|
|
153
|
+
if ((child.kind === 'element' || child.kind === 'component') && child.tagLower === 'caption') captions.push(child);
|
|
154
|
+
else others.push(child);
|
|
155
|
+
}
|
|
156
|
+
return captions.length > 0 ? [...others, ...captions] : children;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function hasTableCellSizeOverride(classes: string[]): boolean {
|
|
160
|
+
for (const cls of classes) {
|
|
161
|
+
const base = getBaseClass(cls);
|
|
162
|
+
if (!base) continue;
|
|
163
|
+
if (base === 'flex-1' || base === 'grow' || base === 'grow-0') return true;
|
|
164
|
+
if (base.startsWith('w-') || base.startsWith('min-w-') || base.startsWith('max-w-')) return true;
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getNumericColSpan(value: unknown): number {
|
|
170
|
+
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(1, Math.floor(value));
|
|
171
|
+
if (typeof value === 'string') {
|
|
172
|
+
const parsed = parseInt(value, 10);
|
|
173
|
+
if (Number.isFinite(parsed)) return Math.max(1, parsed);
|
|
174
|
+
}
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isSemanticTableContainer(node: NodeIR): boolean {
|
|
179
|
+
if (node.kind !== 'element' && node.kind !== 'component') return false;
|
|
180
|
+
return node.tagLower === 'table' || node.tagLower === 'thead' || node.tagLower === 'tbody' || node.tagLower === 'tfoot';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function clearTopBorder(node: SceneNode): void {
|
|
184
|
+
try {
|
|
185
|
+
(node as any).strokeTopWeight = 0;
|
|
186
|
+
} catch (_err) {
|
|
187
|
+
// ignore if unsupported
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function clearBottomBorder(node: SceneNode): void {
|
|
192
|
+
try {
|
|
193
|
+
(node as any).strokeBottomWeight = 0;
|
|
194
|
+
} catch (_err) {
|
|
195
|
+
// ignore if unsupported
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function constrainSingleHorizontalTextChild(frame: any): void {
|
|
200
|
+
if (
|
|
201
|
+
frame.layoutMode !== 'HORIZONTAL'
|
|
202
|
+
|| !Array.isArray(frame.children)
|
|
203
|
+
|| frame.children.length < 2
|
|
204
|
+
) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Keep naturally centered CTA/action rows intact.
|
|
209
|
+
// Constraining text width in CENTER rows makes one text child consume the
|
|
210
|
+
// remaining width, which shifts sibling controls off-center.
|
|
211
|
+
if (frame.primaryAxisAlignItems === 'CENTER') {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Width constraints only make sense once the row width is fixed.
|
|
216
|
+
if (frame.primaryAxisSizingMode !== 'FIXED') {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const textChildren = frame.children.filter((child: any) => child?.type === 'TEXT');
|
|
221
|
+
if (textChildren.length !== 1) return;
|
|
222
|
+
|
|
223
|
+
const nonTextWidth = frame.children
|
|
224
|
+
.filter((child: any) => child?.type !== 'TEXT')
|
|
225
|
+
.reduce((sum: number, child: any) => sum + (child?.width || 0), 0);
|
|
226
|
+
|
|
227
|
+
const availableWidth = Math.max(
|
|
228
|
+
0,
|
|
229
|
+
frame.width
|
|
230
|
+
- (frame.paddingLeft || 0)
|
|
231
|
+
- (frame.paddingRight || 0)
|
|
232
|
+
- (frame.itemSpacing || 0) * Math.max(0, frame.children.length - 1)
|
|
233
|
+
- nonTextWidth,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
if (availableWidth <= 0) return;
|
|
237
|
+
|
|
238
|
+
const textChild = textChildren[0];
|
|
239
|
+
try {
|
|
240
|
+
textChild.textAutoResize = 'HEIGHT';
|
|
241
|
+
textChild.resize(availableWidth, textChild.height);
|
|
242
|
+
} catch (_err) {
|
|
243
|
+
// ignore resize errors
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function stabilizeHorizontalStretchChild(child: any, parent: any): void {
|
|
248
|
+
if (
|
|
249
|
+
!child
|
|
250
|
+
|| child.type !== 'FRAME'
|
|
251
|
+
|| child.layoutMode !== 'HORIZONTAL'
|
|
252
|
+
|| child.layoutAlign !== 'STRETCH'
|
|
253
|
+
|| child.primaryAxisSizingMode === 'FIXED'
|
|
254
|
+
|| !parent
|
|
255
|
+
|| parent.layoutMode !== 'VERTICAL'
|
|
256
|
+
) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const parentContentWidth = Math.max(
|
|
261
|
+
0,
|
|
262
|
+
(parent.width || 0) - (parent.paddingLeft || 0) - (parent.paddingRight || 0),
|
|
263
|
+
);
|
|
264
|
+
if (parentContentWidth <= 0) return;
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
child.resize(parentContentWidth, Math.max(1, child.height || 1));
|
|
268
|
+
child.primaryAxisSizingMode = 'FIXED';
|
|
269
|
+
} catch (_err) {
|
|
270
|
+
// ignore resize errors
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const TEXT_SIZE_PX: Record<string, number> = {
|
|
275
|
+
'text-xs': 12,
|
|
276
|
+
'text-sm': 14,
|
|
277
|
+
'text-base': 16,
|
|
278
|
+
'text-lg': 18,
|
|
279
|
+
'text-xl': 20,
|
|
280
|
+
'text-2xl': 24,
|
|
281
|
+
'text-3xl': 30,
|
|
282
|
+
'text-4xl': 36,
|
|
283
|
+
'text-5xl': 48,
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const FONT_WEIGHT_RANK: Record<string, number> = {
|
|
287
|
+
'font-thin': 1,
|
|
288
|
+
'font-extralight': 2,
|
|
289
|
+
'font-light': 3,
|
|
290
|
+
'font-normal': 4,
|
|
291
|
+
'font-medium': 5,
|
|
292
|
+
'font-semibold': 6,
|
|
293
|
+
'font-bold': 7,
|
|
294
|
+
'font-extrabold': 8,
|
|
295
|
+
'font-black': 9,
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const FONT_WEIGHT_TO_STYLE: Record<string, string> = {
|
|
299
|
+
'font-thin': 'Thin',
|
|
300
|
+
'font-extralight': 'ExtraLight',
|
|
301
|
+
'font-light': 'Light',
|
|
302
|
+
'font-normal': 'Regular',
|
|
303
|
+
'font-medium': 'Medium',
|
|
304
|
+
'font-semibold': 'SemiBold',
|
|
305
|
+
'font-bold': 'Bold',
|
|
306
|
+
'font-extrabold': 'ExtraBold',
|
|
307
|
+
'font-black': 'Black',
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
function getFontStyleFromClasses(classes: string[], fallback?: string): string | undefined {
|
|
311
|
+
let found: string | undefined;
|
|
312
|
+
for (const cls of classes) {
|
|
313
|
+
const style = FONT_WEIGHT_TO_STYLE[cls];
|
|
314
|
+
if (style) { found = style; }
|
|
315
|
+
}
|
|
316
|
+
if (found == null && fallback == null) return undefined;
|
|
317
|
+
return found != null ? found : fallback;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function getResolvedTextSizeFromClasses(classes: string[], fallback?: number): number | undefined {
|
|
321
|
+
let resolved = fallback;
|
|
322
|
+
for (const cls of classes) {
|
|
323
|
+
if (TEXT_SIZE_PX[cls] != null) resolved = TEXT_SIZE_PX[cls];
|
|
324
|
+
}
|
|
325
|
+
return resolved;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function getResolvedBoldFromClasses(classes: string[], fallback?: boolean): boolean | undefined {
|
|
329
|
+
let rank = fallback ? 7 : 4;
|
|
330
|
+
let seenWeight = false;
|
|
331
|
+
for (const cls of classes) {
|
|
332
|
+
if (FONT_WEIGHT_RANK[cls] != null) {
|
|
333
|
+
rank = FONT_WEIGHT_RANK[cls];
|
|
334
|
+
seenWeight = true;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (!seenWeight && fallback == null) return undefined;
|
|
338
|
+
return rank >= FONT_WEIGHT_RANK['font-semibold'];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function getResponsiveConflictGroup(utility: string): string | null {
|
|
342
|
+
if (
|
|
343
|
+
utility === 'flex-row' ||
|
|
344
|
+
utility === 'flex-row-reverse' ||
|
|
345
|
+
utility === 'flex-col' ||
|
|
346
|
+
utility === 'flex-col-reverse'
|
|
347
|
+
) {
|
|
348
|
+
return 'layout-direction';
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (
|
|
352
|
+
utility === 'justify-start' ||
|
|
353
|
+
utility === 'justify-center' ||
|
|
354
|
+
utility === 'justify-end' ||
|
|
355
|
+
utility === 'justify-between' ||
|
|
356
|
+
utility === 'justify-around' ||
|
|
357
|
+
utility === 'justify-evenly'
|
|
358
|
+
) {
|
|
359
|
+
return 'main-align';
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (
|
|
363
|
+
utility === 'items-start' ||
|
|
364
|
+
utility === 'items-center' ||
|
|
365
|
+
utility === 'items-end' ||
|
|
366
|
+
utility === 'items-stretch' ||
|
|
367
|
+
utility === 'items-baseline'
|
|
368
|
+
) {
|
|
369
|
+
return 'cross-align';
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (
|
|
373
|
+
utility === 'text-left' ||
|
|
374
|
+
utility === 'text-center' ||
|
|
375
|
+
utility === 'text-right' ||
|
|
376
|
+
utility === 'text-justify'
|
|
377
|
+
) {
|
|
378
|
+
return 'text-align';
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function resolveClassesForBreakpoint(classes: string[], breakpointIndex: number): string[] {
|
|
385
|
+
if (!classes || classes.length === 0) return classes || [];
|
|
386
|
+
|
|
387
|
+
const out: string[] = [];
|
|
388
|
+
const conflictIndex = new Map<string, number>();
|
|
389
|
+
for (let i = 0; i < classes.length; i++) {
|
|
390
|
+
const atom = parseUtilityClass(classes[i]);
|
|
391
|
+
if (!atom.utility) continue;
|
|
392
|
+
|
|
393
|
+
let requiredBreakpoint = -1;
|
|
394
|
+
const remainingVariants: string[] = [];
|
|
395
|
+
for (let j = 0; j < atom.variants.length; j++) {
|
|
396
|
+
const variant = atom.variants[j];
|
|
397
|
+
const responsiveIdx = RESPONSIVE_BREAKPOINT_ORDER.indexOf(variant as any);
|
|
398
|
+
if (responsiveIdx !== -1) {
|
|
399
|
+
if (responsiveIdx > requiredBreakpoint) requiredBreakpoint = responsiveIdx;
|
|
400
|
+
} else {
|
|
401
|
+
remainingVariants.push(variant);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (requiredBreakpoint !== -1 && breakpointIndex < requiredBreakpoint) {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const rebuilt = remainingVariants.length > 0
|
|
410
|
+
? remainingVariants.join(':') + ':' + atom.utility
|
|
411
|
+
: atom.utility;
|
|
412
|
+
const finalClass = atom.important ? '!' + rebuilt : rebuilt;
|
|
413
|
+
const conflictGroup = getResponsiveConflictGroup(atom.utility);
|
|
414
|
+
if (conflictGroup) {
|
|
415
|
+
const variantScope = remainingVariants.join(':');
|
|
416
|
+
const conflictKey = `${variantScope}|${conflictGroup}`;
|
|
417
|
+
const existingIndex = conflictIndex.get(conflictKey);
|
|
418
|
+
if (existingIndex !== undefined) {
|
|
419
|
+
out[existingIndex] = finalClass;
|
|
420
|
+
} else {
|
|
421
|
+
conflictIndex.set(conflictKey, out.length);
|
|
422
|
+
out.push(finalClass);
|
|
423
|
+
}
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
out.push(finalClass);
|
|
428
|
+
}
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function resolveNodeResponsiveClasses(node: NodeIR, width?: number): NodeIR {
|
|
433
|
+
const breakpointIndex = resolveBreakpointIndexForWidth(width);
|
|
434
|
+
if (breakpointIndex === -1) return node;
|
|
435
|
+
|
|
436
|
+
if (node.kind === 'text' || node.kind === 'divider') return node;
|
|
437
|
+
|
|
438
|
+
if (node.kind === 'fragment') {
|
|
439
|
+
return Object.assign({}, node, {
|
|
440
|
+
children: node.children.map(child => resolveNodeResponsiveClasses(child, width)),
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (node.kind === 'ring') {
|
|
445
|
+
return Object.assign({}, node, {
|
|
446
|
+
classes: resolveClassesForBreakpoint(node.classes, breakpointIndex),
|
|
447
|
+
child: resolveNodeResponsiveClasses(node.child, width),
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return Object.assign({}, node, {
|
|
452
|
+
classes: resolveClassesForBreakpoint(node.classes, breakpointIndex),
|
|
453
|
+
children: node.children.map(child => resolveNodeResponsiveClasses(child, width)),
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// Constants (Icon code moved to icon-builder.ts)
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
// Functions
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
function resolveContextTextColorValue(
|
|
466
|
+
context: RenderContext,
|
|
467
|
+
colorGroup: Record<string, string>,
|
|
468
|
+
theme: string
|
|
469
|
+
): string | RGB | null {
|
|
470
|
+
return resolveTextColorValue(context.textColor, context.textColorToken, colorGroup, theme);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// SVG/icon functions moved to icon-builder.ts
|
|
474
|
+
|
|
475
|
+
function resolveIconForegroundColor(
|
|
476
|
+
context: RenderContext,
|
|
477
|
+
colorGroup: Record<string, string>,
|
|
478
|
+
theme: string
|
|
479
|
+
): RGB {
|
|
480
|
+
const resolvedTextColor = resolveContextTextColorValue(context, colorGroup, theme);
|
|
481
|
+
if (resolvedTextColor) return parseColor(resolvedTextColor);
|
|
482
|
+
if (colorGroup.foreground) return parseColor(colorGroup.foreground);
|
|
483
|
+
return { r: 0.1, g: 0.1, b: 0.1 };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function wrapSvgIcon(
|
|
487
|
+
icon: SceneNode,
|
|
488
|
+
classes: string[],
|
|
489
|
+
props: Record<string, string>,
|
|
490
|
+
name: string
|
|
491
|
+
): FrameNode {
|
|
492
|
+
const size = resolveIconSizeFromClasses(classes, props);
|
|
493
|
+
resizeSvgNodeTo(icon, size.width, size.height);
|
|
494
|
+
const usage = getVectorPaintUsage(icon);
|
|
495
|
+
const pad = usage.hasStrokes ? 1 : 0;
|
|
496
|
+
return wrapIconNode(icon, size.width, size.height, pad, name);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function wrapMappedIcon(
|
|
500
|
+
icon: SceneNode,
|
|
501
|
+
iconKey: string,
|
|
502
|
+
tagName: string,
|
|
503
|
+
classes: string[],
|
|
504
|
+
props: Record<string, string>,
|
|
505
|
+
rotateAccordionChevron: boolean
|
|
506
|
+
): FrameNode {
|
|
507
|
+
const size = resolveIconSizeFromClasses(classes, props);
|
|
508
|
+
resizeSvgNodeTo(icon, size.width, size.height);
|
|
509
|
+
const pad = ICON_PATHS[iconKey].stroke
|
|
510
|
+
? Math.max(1, Math.ceil((ICON_PATHS[iconKey].strokeWidth || 1.5) / 2))
|
|
511
|
+
: 0;
|
|
512
|
+
const wrapped = wrapIconNode(icon, size.width, size.height, pad, `icon/${iconKey}`);
|
|
513
|
+
if (rotateAccordionChevron && isAccordionChevronTag(tagName)) {
|
|
514
|
+
wrapped.rotation = 180;
|
|
515
|
+
}
|
|
516
|
+
return wrapped;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function renderRegistryIcon(
|
|
520
|
+
tagName: string,
|
|
521
|
+
classes: string[],
|
|
522
|
+
props: Record<string, string>,
|
|
523
|
+
fgColor: RGB
|
|
524
|
+
): FrameNode | null {
|
|
525
|
+
const iconEntry = getIconRegistryEntry(tagName);
|
|
526
|
+
if (!iconEntry || !iconEntry.svg) return null;
|
|
527
|
+
const icon = createIconFromSvg(iconEntry.svg, fgColor);
|
|
528
|
+
if (!icon) return null;
|
|
529
|
+
return wrapSvgIcon(icon, classes, props, `icon/${tagName}`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function renderMappedIcon(
|
|
533
|
+
tagName: string,
|
|
534
|
+
classes: string[],
|
|
535
|
+
props: Record<string, string>,
|
|
536
|
+
fgColor: RGB,
|
|
537
|
+
rotateAccordionChevron: boolean
|
|
538
|
+
): FrameNode | null {
|
|
539
|
+
const iconKey = getReactIconKey(tagName);
|
|
540
|
+
if (!iconKey || !ICON_PATHS[iconKey]) return null;
|
|
541
|
+
const icon = createIcon(iconKey, fgColor);
|
|
542
|
+
if (!icon) return null;
|
|
543
|
+
return wrapMappedIcon(icon, iconKey, tagName, classes, props, rotateAccordionChevron);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function normalizeComponentDef(raw: any): any {
|
|
547
|
+
if (!raw || !raw.analysis) return raw;
|
|
548
|
+
|
|
549
|
+
// Scanner output stores component metadata (stories, layout, responsive, colorScheme)
|
|
550
|
+
// alongside the nested `analysis`. Many render paths want the analysis shape but still
|
|
551
|
+
// need the attached metadata, so preserve it when normalizing.
|
|
552
|
+
return {
|
|
553
|
+
...raw.analysis,
|
|
554
|
+
stories: Array.isArray(raw.analysis.stories)
|
|
555
|
+
? raw.analysis.stories
|
|
556
|
+
: (Array.isArray(raw.stories) ? raw.stories : []),
|
|
557
|
+
hasStory: typeof raw.analysis.hasStory === 'boolean'
|
|
558
|
+
? raw.analysis.hasStory
|
|
559
|
+
: !!raw.hasStory,
|
|
560
|
+
layout: raw.layout,
|
|
561
|
+
responsive: raw.responsive,
|
|
562
|
+
colorScheme: raw.colorScheme,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function nodeHasClipPathDescendant(node: NodeIR): boolean {
|
|
567
|
+
if (node.kind === 'text' || node.kind === 'divider') return false;
|
|
568
|
+
if (node.kind === 'ring') return nodeHasClipPathDescendant(node.child);
|
|
569
|
+
if (node.kind === 'fragment') {
|
|
570
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
571
|
+
if (nodeHasClipPathDescendant(node.children[i])) return true;
|
|
572
|
+
}
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
if (node.kind === 'element') {
|
|
576
|
+
if (parseClipPathFromStyle(node.props.style)) return true;
|
|
577
|
+
}
|
|
578
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
579
|
+
if (nodeHasClipPathDescendant(node.children[i])) return true;
|
|
580
|
+
}
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Render a component using its internal jsxTree structure.
|
|
586
|
+
* This handles components with decorative elements (gradient backgrounds, blurred shapes, etc.)
|
|
587
|
+
* by rendering the component's actual DOM structure instead of a simple wrapper.
|
|
588
|
+
*/
|
|
589
|
+
function renderComponentWithJsxTree(
|
|
590
|
+
jsxTree: JsxNode,
|
|
591
|
+
actualChildren: NodeIR[],
|
|
592
|
+
colorGroup: Record<string, string>,
|
|
593
|
+
radiusGroup: Record<string, string> | null,
|
|
594
|
+
theme: string,
|
|
595
|
+
depth: number,
|
|
596
|
+
context: RenderContext
|
|
597
|
+
): SceneNode | null {
|
|
598
|
+
// Convert the component's jsxTree to NodeIR and render it
|
|
599
|
+
// When we encounter {children} text, substitute with actualChildren
|
|
600
|
+
const substitutedTree = substituteChildrenInJsxTree(jsxTree, actualChildren);
|
|
601
|
+
if (!substitutedTree) return null;
|
|
602
|
+
|
|
603
|
+
// Resolve and render the substituted tree
|
|
604
|
+
const resolved = resolveNodeIR(substitutedTree);
|
|
605
|
+
if (!resolved) return null;
|
|
606
|
+
|
|
607
|
+
const nodeHelpers: NodeIRHelpers = {
|
|
608
|
+
getComponentDefByName: getComponentDefByName,
|
|
609
|
+
normalizeComponentDef: normalizeComponentDef,
|
|
610
|
+
getCompoundClasses: getCompoundClasses,
|
|
611
|
+
mergeClasses: mergeClasses,
|
|
612
|
+
};
|
|
613
|
+
const transformed = applyNodeTransforms(resolved, colorGroup, nodeHelpers);
|
|
614
|
+
const responsiveResolved = resolveNodeResponsiveClasses(transformed, context.maxWidth);
|
|
615
|
+
const solved = solveLayoutWidths(responsiveResolved, context.maxWidth);
|
|
616
|
+
return buildFigmaNode(solved, colorGroup, radiusGroup, theme, depth, context);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Recursively substitute {children} text nodes with actual children from the story.
|
|
621
|
+
*/
|
|
622
|
+
function substituteChildrenInJsxTree(
|
|
623
|
+
jsxNode: JsxNode,
|
|
624
|
+
actualChildren: NodeIR[]
|
|
625
|
+
): JsxNode | null {
|
|
626
|
+
if (jsxNode.type === 'text') {
|
|
627
|
+
const textNode = jsxNode as JsxText;
|
|
628
|
+
// Check if this is a {children} placeholder
|
|
629
|
+
if (textNode.content.trim() === '{children}') {
|
|
630
|
+
// Return a fragment-like structure - we'll handle this in element processing
|
|
631
|
+
return null; // Will be handled specially in element processing
|
|
632
|
+
}
|
|
633
|
+
return jsxNode;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const element = jsxNode as JsxElement;
|
|
637
|
+
const newChildren: JsxNode[] = [];
|
|
638
|
+
|
|
639
|
+
for (const child of element.children || []) {
|
|
640
|
+
if (child.type === 'text') {
|
|
641
|
+
const textChild = child as JsxText;
|
|
642
|
+
if (textChild.content.trim() === '{children}') {
|
|
643
|
+
// Convert NodeIR children back to JsxNode format for consistency
|
|
644
|
+
for (const actualChild of actualChildren) {
|
|
645
|
+
const jsxChild = nodeIRToJsxNode(actualChild);
|
|
646
|
+
if (jsxChild) {
|
|
647
|
+
newChildren.push(jsxChild);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
const substituted = substituteChildrenInJsxTree(child, actualChildren);
|
|
654
|
+
if (substituted) {
|
|
655
|
+
newChildren.push(substituted);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return {
|
|
660
|
+
type: 'element',
|
|
661
|
+
tagName: element.tagName,
|
|
662
|
+
isComponent: element.isComponent,
|
|
663
|
+
props: element.props,
|
|
664
|
+
children: newChildren,
|
|
665
|
+
} as JsxElement;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Check whether a component jsxTree explicitly exposes a {children} slot.
|
|
670
|
+
* If it does not, rendering the tree for an instance with children would drop
|
|
671
|
+
* those children, so callers should use the wrapper fallback instead.
|
|
672
|
+
*/
|
|
673
|
+
function jsxTreeHasChildrenSlot(jsxNode: JsxNode | null | undefined): boolean {
|
|
674
|
+
if (!jsxNode) return false;
|
|
675
|
+
if (jsxNode.type === 'text') {
|
|
676
|
+
const content = (jsxNode as JsxText).content.trim();
|
|
677
|
+
return /^\{\s*children\s*\}$/.test(content);
|
|
678
|
+
}
|
|
679
|
+
const element = jsxNode as JsxElement;
|
|
680
|
+
for (const child of element.children || []) {
|
|
681
|
+
if (jsxTreeHasChildrenSlot(child)) return true;
|
|
682
|
+
}
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Convert a NodeIR node back to JsxNode format.
|
|
688
|
+
*/
|
|
689
|
+
function nodeIRToJsxNode(node: NodeIR): JsxNode | null {
|
|
690
|
+
if (node.kind === 'text') {
|
|
691
|
+
return { type: 'text', content: node.text } as JsxText;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (node.kind === 'element' || node.kind === 'component') {
|
|
695
|
+
const children: JsxNode[] = [];
|
|
696
|
+
for (const child of node.children || []) {
|
|
697
|
+
const jsxChild = nodeIRToJsxNode(child);
|
|
698
|
+
if (jsxChild) {
|
|
699
|
+
children.push(jsxChild);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return {
|
|
703
|
+
type: 'element',
|
|
704
|
+
tagName: node.tagName,
|
|
705
|
+
isComponent: node.kind === 'component',
|
|
706
|
+
props: Object.assign({}, node.props, { className: node.classes.join(' ') }),
|
|
707
|
+
children,
|
|
708
|
+
} as JsxElement;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (node.kind === 'fragment') {
|
|
712
|
+
// Wrap fragment children in a div
|
|
713
|
+
const children: JsxNode[] = [];
|
|
714
|
+
for (const child of node.children || []) {
|
|
715
|
+
const jsxChild = nodeIRToJsxNode(child);
|
|
716
|
+
if (jsxChild) {
|
|
717
|
+
children.push(jsxChild);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return {
|
|
721
|
+
type: 'element',
|
|
722
|
+
tagName: 'div',
|
|
723
|
+
isComponent: false,
|
|
724
|
+
props: {},
|
|
725
|
+
children,
|
|
726
|
+
} as JsxElement;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// ---------------------------------------------------------------------------
|
|
733
|
+
// JSX Tree Rendering
|
|
734
|
+
// ---------------------------------------------------------------------------
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Create a separator node for divide-y/divide-x
|
|
738
|
+
*/
|
|
739
|
+
function createDivideSeparator(
|
|
740
|
+
direction: 'HORIZONTAL' | 'VERTICAL',
|
|
741
|
+
color: RGB,
|
|
742
|
+
parentWidth: number
|
|
743
|
+
): FrameNode {
|
|
744
|
+
const sep = figma.createFrame();
|
|
745
|
+
sep.name = 'divider';
|
|
746
|
+
applyClipBehavior(sep, []);
|
|
747
|
+
|
|
748
|
+
if (direction === 'HORIZONTAL') {
|
|
749
|
+
// Horizontal line (for divide-y in vertical list)
|
|
750
|
+
sep.resize(Math.max(parentWidth, 100), 1);
|
|
751
|
+
sep.layoutAlign = 'STRETCH';
|
|
752
|
+
} else {
|
|
753
|
+
// Vertical line (for divide-x in horizontal list)
|
|
754
|
+
sep.resize(1, 24); // Height will adjust
|
|
755
|
+
sep.layoutGrow = 0;
|
|
756
|
+
sep.layoutAlign = 'STRETCH';
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
sep.fills = [{ type: 'SOLID', color: { r: color.r, g: color.g, b: color.b }, opacity: 1 }];
|
|
760
|
+
return sep;
|
|
761
|
+
}
|
|
762
|
+
function resolveCornerRadiusFromClasses(
|
|
763
|
+
classes: string[],
|
|
764
|
+
radiusGroup: Record<string, string> | null
|
|
765
|
+
): number | null {
|
|
766
|
+
if (classes.includes('rounded-full')) return 9999;
|
|
767
|
+
if (classes.includes('rounded-none')) return 0;
|
|
768
|
+
|
|
769
|
+
for (const cls of classes) {
|
|
770
|
+
const match = cls.match(/^rounded-\[(\d+(?:\.\d+)?)px\]$/);
|
|
771
|
+
if (match) return parseFloat(match[1]);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const tokenMap: Record<string, string> = {
|
|
775
|
+
'rounded-sm': 'sm',
|
|
776
|
+
'rounded': 'base',
|
|
777
|
+
'rounded-md': 'md',
|
|
778
|
+
'rounded-lg': 'lg',
|
|
779
|
+
'rounded-xl': 'xl',
|
|
780
|
+
'rounded-2xl': '2xl',
|
|
781
|
+
'rounded-3xl': '3xl',
|
|
782
|
+
};
|
|
783
|
+
const fallbackMap: Record<string, number> = {
|
|
784
|
+
sm: 2,
|
|
785
|
+
base: 4,
|
|
786
|
+
md: 6,
|
|
787
|
+
lg: 8,
|
|
788
|
+
xl: 12,
|
|
789
|
+
'2xl': 16,
|
|
790
|
+
'3xl': 24,
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
for (const cls of classes) {
|
|
794
|
+
const tokenKey = tokenMap[cls];
|
|
795
|
+
if (!tokenKey) continue;
|
|
796
|
+
if (radiusGroup && radiusGroup[tokenKey]) {
|
|
797
|
+
return pxFromSizeToken(radiusGroup[tokenKey]);
|
|
798
|
+
}
|
|
799
|
+
if (fallbackMap[tokenKey] != null) return fallbackMap[tokenKey];
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Renders a JSX tree from the scanner into Figma frames
|
|
807
|
+
*/
|
|
808
|
+
function getStoryBuilderContext(): StoryBuilderContext {
|
|
809
|
+
return {
|
|
810
|
+
getComponentDefByName: getComponentDefByName,
|
|
811
|
+
normalizeComponentDef: normalizeComponentDef,
|
|
812
|
+
applyClipBehavior: applyClipBehavior,
|
|
813
|
+
applyLayoutClasses: applyLayoutClasses,
|
|
814
|
+
hasExplicitHeight: hasExplicitHeight,
|
|
815
|
+
renderJsxTree: renderJsxTree,
|
|
816
|
+
applyAbsoluteIfAllowed: applyAbsoluteIfAllowed,
|
|
817
|
+
applyGridColumnsWithReflow: applyGridColumnsWithReflow,
|
|
818
|
+
hasWidthHintInClasses: hasWidthHintInClasses,
|
|
819
|
+
propsContainWidthHint: propsContainWidthHint,
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
export function renderJsxTree(
|
|
825
|
+
node: JsxNode,
|
|
826
|
+
colorGroup: Record<string, string>,
|
|
827
|
+
radiusGroup: Record<string, string> | null,
|
|
828
|
+
theme: string,
|
|
829
|
+
depth: number = 0,
|
|
830
|
+
context: RenderContext = {}
|
|
831
|
+
): SceneNode | null {
|
|
832
|
+
const resolved = resolveNodeIR(node);
|
|
833
|
+
if (!resolved) return null;
|
|
834
|
+
const nodeHelpers: NodeIRHelpers = {
|
|
835
|
+
getComponentDefByName: getComponentDefByName,
|
|
836
|
+
normalizeComponentDef: normalizeComponentDef,
|
|
837
|
+
getCompoundClasses: getCompoundClasses,
|
|
838
|
+
mergeClasses: mergeClasses,
|
|
839
|
+
};
|
|
840
|
+
const transformed = applyNodeTransforms(resolved, colorGroup, nodeHelpers);
|
|
841
|
+
const responsiveResolved = resolveNodeResponsiveClasses(transformed, context.maxWidth);
|
|
842
|
+
const solved = solveLayoutWidths(responsiveResolved, context.maxWidth);
|
|
843
|
+
return buildFigmaNode(solved, colorGroup, radiusGroup, theme, depth, context);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function buildFigmaNode(
|
|
847
|
+
node: NodeIR,
|
|
848
|
+
colorGroup: Record<string, string>,
|
|
849
|
+
radiusGroup: Record<string, string> | null,
|
|
850
|
+
theme: string,
|
|
851
|
+
depth: number,
|
|
852
|
+
context: RenderContext
|
|
853
|
+
): SceneNode | null {
|
|
854
|
+
if (node.kind === 'text') {
|
|
855
|
+
const opts: any = { fontSize: context.textFontSize || 14, theme };
|
|
856
|
+
if (context.textFontStyle != null) {
|
|
857
|
+
opts.fontStyle = context.textFontStyle;
|
|
858
|
+
} else if (context.textBold != null) {
|
|
859
|
+
opts.bold = context.textBold;
|
|
860
|
+
}
|
|
861
|
+
if (context.textLineHeight != null) {
|
|
862
|
+
opts.lineHeight = context.textLineHeight;
|
|
863
|
+
}
|
|
864
|
+
if (context.textColor) {
|
|
865
|
+
opts.fill = context.textColor;
|
|
866
|
+
}
|
|
867
|
+
if (context.textAlign) {
|
|
868
|
+
opts.textAlignHorizontal = context.textAlign;
|
|
869
|
+
}
|
|
870
|
+
const textNode = createTextNode(node.text, opts);
|
|
871
|
+
if (context.textTruncate && context.maxWidth) {
|
|
872
|
+
try {
|
|
873
|
+
const maxLines = context.textMaxLines || 1;
|
|
874
|
+
const fontSize = context.textFontSize || 14;
|
|
875
|
+
const lineH = context.textLineHeight || Math.ceil(fontSize * 1.5);
|
|
876
|
+
(textNode as any).textTruncation = 'ENDING';
|
|
877
|
+
(textNode as any).maxLines = maxLines;
|
|
878
|
+
textNode.resize(context.maxWidth, Math.max(lineH, lineH * maxLines));
|
|
879
|
+
} catch (_err) {
|
|
880
|
+
// ignore — truncation not supported in all plugin contexts
|
|
881
|
+
}
|
|
882
|
+
} else if (context.maxWidth && context.parentLayout !== 'HORIZONTAL') {
|
|
883
|
+
try {
|
|
884
|
+
textNode.textAutoResize = 'HEIGHT';
|
|
885
|
+
textNode.resize(context.maxWidth, textNode.height);
|
|
886
|
+
} catch (_err) {
|
|
887
|
+
// ignore resize errors
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return textNode;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (node.kind === 'fragment') {
|
|
894
|
+
if (node.children.length === 0) return null;
|
|
895
|
+
const wrapper = figma.createFrame();
|
|
896
|
+
wrapper.name = 'Fragment';
|
|
897
|
+
wrapper.layoutMode = 'VERTICAL';
|
|
898
|
+
wrapper.primaryAxisSizingMode = 'AUTO';
|
|
899
|
+
wrapper.counterAxisSizingMode = 'AUTO';
|
|
900
|
+
wrapper.fills = [];
|
|
901
|
+
applyClipBehavior(wrapper, []);
|
|
902
|
+
for (const child of node.children) {
|
|
903
|
+
const childNode = buildFigmaNode(child, colorGroup, radiusGroup, theme, depth + 1, context);
|
|
904
|
+
if (childNode) {
|
|
905
|
+
wrapper.appendChild(childNode);
|
|
906
|
+
if (isElementLikeNode(child)) {
|
|
907
|
+
LayoutParser.applyChildProperties(childNode, child.classes, wrapper);
|
|
908
|
+
}
|
|
909
|
+
stabilizeHorizontalStretchChild(childNode, wrapper);
|
|
910
|
+
constrainSingleHorizontalTextChild(childNode);
|
|
911
|
+
applyAbsoluteIfPossible(childNode, wrapper);
|
|
912
|
+
applyFullWidthIfPossible(childNode, wrapper, context.maxWidth != null ? { widthOverride: context.maxWidth } : undefined);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
applyDeferredBottomPositioning(wrapper);
|
|
916
|
+
applyDeferredCenterYPositioning(wrapper);
|
|
917
|
+
return wrapper;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (node.kind === 'divider') {
|
|
921
|
+
const parentWidth = context.maxWidth || 200;
|
|
922
|
+
return createDivideSeparator(node.direction, node.color, parentWidth);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if (node.kind === 'ring') {
|
|
926
|
+
const ring = figma.createFrame();
|
|
927
|
+
ring.name = 'ring';
|
|
928
|
+
ring.layoutMode = 'HORIZONTAL';
|
|
929
|
+
ring.primaryAxisSizingMode = 'AUTO';
|
|
930
|
+
ring.counterAxisSizingMode = 'AUTO';
|
|
931
|
+
applyClipBehavior(ring, []);
|
|
932
|
+
// Render ring as a stroke (CSS box-shadow equivalent), not a fill.
|
|
933
|
+
// A fill-based ring bleeds through transparent child backgrounds; strokes match CSS behavior.
|
|
934
|
+
ring.fills = [];
|
|
935
|
+
ring.strokes = [{
|
|
936
|
+
type: 'SOLID',
|
|
937
|
+
color: { r: node.ringColor.r, g: node.ringColor.g, b: node.ringColor.b },
|
|
938
|
+
opacity: node.ringOpacity,
|
|
939
|
+
}];
|
|
940
|
+
(ring as any).strokeWeight = node.ringWidth;
|
|
941
|
+
(ring as any).strokeAlign = 'OUTSIDE';
|
|
942
|
+
// No padding needed — stroke draws on the frame border
|
|
943
|
+
|
|
944
|
+
let baseRadius: number | null = null;
|
|
945
|
+
if (isElementLikeNode(node.child)) {
|
|
946
|
+
baseRadius = resolveCornerRadiusFromClasses(node.child.classes, radiusGroup);
|
|
947
|
+
}
|
|
948
|
+
if (baseRadius == null && radiusGroup && radiusGroup.base) {
|
|
949
|
+
baseRadius = pxFromSizeToken(radiusGroup.base);
|
|
950
|
+
}
|
|
951
|
+
if (baseRadius == null) baseRadius = 0;
|
|
952
|
+
ring.cornerRadius = baseRadius + node.offsetWidth;
|
|
953
|
+
|
|
954
|
+
let innerParent: FrameNode = ring;
|
|
955
|
+
if (node.offsetWidth > 0 && node.offsetColor) {
|
|
956
|
+
const offsetFrame = figma.createFrame();
|
|
957
|
+
offsetFrame.name = 'ring-offset';
|
|
958
|
+
offsetFrame.layoutMode = 'HORIZONTAL';
|
|
959
|
+
offsetFrame.primaryAxisSizingMode = 'AUTO';
|
|
960
|
+
offsetFrame.counterAxisSizingMode = 'AUTO';
|
|
961
|
+
applyClipBehavior(offsetFrame, []);
|
|
962
|
+
offsetFrame.fills = [{
|
|
963
|
+
type: 'SOLID',
|
|
964
|
+
color: { r: node.offsetColor.r, g: node.offsetColor.g, b: node.offsetColor.b },
|
|
965
|
+
opacity: 1,
|
|
966
|
+
}];
|
|
967
|
+
offsetFrame.strokes = [];
|
|
968
|
+
offsetFrame.paddingLeft = offsetFrame.paddingRight = offsetFrame.paddingTop = offsetFrame.paddingBottom = node.offsetWidth;
|
|
969
|
+
offsetFrame.cornerRadius = baseRadius + node.offsetWidth;
|
|
970
|
+
ring.appendChild(offsetFrame);
|
|
971
|
+
innerParent = offsetFrame;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const childNode = buildFigmaNode(node.child, colorGroup, radiusGroup, theme, depth + 1, context);
|
|
975
|
+
if (childNode) {
|
|
976
|
+
innerParent.appendChild(childNode);
|
|
977
|
+
if (isElementLikeNode(node.child)) {
|
|
978
|
+
LayoutParser.applyChildProperties(childNode, node.child.classes, innerParent);
|
|
979
|
+
}
|
|
980
|
+
stabilizeHorizontalStretchChild(childNode, innerParent);
|
|
981
|
+
constrainSingleHorizontalTextChild(childNode);
|
|
982
|
+
applyAbsoluteIfPossible(childNode, innerParent);
|
|
983
|
+
applyFullWidthIfPossible(childNode, innerParent, context.maxWidth != null ? { widthOverride: context.maxWidth } : undefined);
|
|
984
|
+
return maybeWrapMxAuto(ring, node.classes, context);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
return null;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const tagLower = node.tagLower;
|
|
991
|
+
const breakpointIndex = resolveBreakpointIndexForWidth(
|
|
992
|
+
context.maxWidth != null ? context.maxWidth : defaultGridWidth(3)
|
|
993
|
+
);
|
|
994
|
+
let classes = resolveClassesForBreakpoint(node.classes, breakpointIndex);
|
|
995
|
+
if (isAccordionHeaderTag(node.tagName) && !classes.includes('w-full')) {
|
|
996
|
+
classes = mergeClasses(classes, ['w-full']);
|
|
997
|
+
}
|
|
998
|
+
const layoutComputed = getNodeLayoutComputed(node);
|
|
999
|
+
const alignFromClasses = getTextAlignFromClasses(classes);
|
|
1000
|
+
const textStyle = getNodeTextStyle(node, colorGroup);
|
|
1001
|
+
const textToken = textStyle.textToken || extractTextColorToken(classes) || context.textColorToken || null;
|
|
1002
|
+
const fallbackTextColor = resolveTextColorFallback(classes, colorGroup, theme);
|
|
1003
|
+
const ownTextColor = resolveTextColorValue(
|
|
1004
|
+
textStyle.text || fallbackTextColor,
|
|
1005
|
+
textToken,
|
|
1006
|
+
colorGroup,
|
|
1007
|
+
theme
|
|
1008
|
+
);
|
|
1009
|
+
const ownTextFontSize = getResolvedTextSizeFromClasses(classes, context.textFontSize);
|
|
1010
|
+
const ownTextBold = getResolvedBoldFromClasses(classes, context.textBold);
|
|
1011
|
+
const ownTextFontStyle = getFontStyleFromClasses(classes, context.textFontStyle);
|
|
1012
|
+
const ownTextLineHeight = ownTextFontSize != null
|
|
1013
|
+
? (getLineHeightFromClasses(classes, ownTextFontSize) ?? context.textLineHeight)
|
|
1014
|
+
: context.textLineHeight;
|
|
1015
|
+
const inheritedTextColor = resolveTextColorValue(
|
|
1016
|
+
context.textColor,
|
|
1017
|
+
context.textColorToken,
|
|
1018
|
+
colorGroup,
|
|
1019
|
+
theme
|
|
1020
|
+
);
|
|
1021
|
+
const resolvedTextColor = ownTextColor || inheritedTextColor;
|
|
1022
|
+
let contextualMaxWidth = context.maxWidth;
|
|
1023
|
+
if (layoutComputed.explicitWidth != null) {
|
|
1024
|
+
contextualMaxWidth = layoutComputed.explicitWidth;
|
|
1025
|
+
} else if (layoutComputed.maxWidth != null) {
|
|
1026
|
+
contextualMaxWidth = context.maxWidth != null
|
|
1027
|
+
? Math.min(layoutComputed.maxWidth, context.maxWidth)
|
|
1028
|
+
: layoutComputed.maxWidth;
|
|
1029
|
+
}
|
|
1030
|
+
// Detect text truncation from classes on this element; reset per-element (don't inherit)
|
|
1031
|
+
let _truncate: boolean | undefined;
|
|
1032
|
+
let _maxLines: number | undefined;
|
|
1033
|
+
if (classes.includes('truncate')) {
|
|
1034
|
+
_truncate = true; _maxLines = 1;
|
|
1035
|
+
} else {
|
|
1036
|
+
for (const cls of classes) {
|
|
1037
|
+
const m = cls.match(/^line-clamp-(\d+)$/);
|
|
1038
|
+
if (m) { _truncate = true; _maxLines = parseInt(m[1], 10); break; }
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
const nextContext: RenderContext = {
|
|
1042
|
+
...context,
|
|
1043
|
+
textAlign: alignFromClasses || context.textAlign,
|
|
1044
|
+
maxWidth: contextualMaxWidth,
|
|
1045
|
+
textFontSize: ownTextFontSize,
|
|
1046
|
+
textBold: ownTextBold,
|
|
1047
|
+
textFontStyle: ownTextFontStyle,
|
|
1048
|
+
textLineHeight: ownTextLineHeight,
|
|
1049
|
+
textColor: resolvedTextColor,
|
|
1050
|
+
textColorToken: textToken || context.textColorToken,
|
|
1051
|
+
textTruncate: _truncate,
|
|
1052
|
+
textMaxLines: _maxLines,
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
if (isAccordionRootTag(node.tagName)) {
|
|
1056
|
+
nextContext.accordionOpenValue = normalizeAccordionDefaultValue(node.props?.defaultValue);
|
|
1057
|
+
nextContext.accordionItemIndex = undefined;
|
|
1058
|
+
nextContext.accordionItemValue = null;
|
|
1059
|
+
nextContext.accordionItemIsOpen = false;
|
|
1060
|
+
} else if (isAccordionItemTag(node.tagName)) {
|
|
1061
|
+
const accordionItemValue = resolveAccordionItemValue(node.props?.value, context.accordionItemIndex);
|
|
1062
|
+
nextContext.accordionItemValue = accordionItemValue;
|
|
1063
|
+
nextContext.accordionItemIsOpen = !!accordionItemValue &&
|
|
1064
|
+
!!nextContext.accordionOpenValue &&
|
|
1065
|
+
accordionItemValue === nextContext.accordionOpenValue;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
if (isAccordionContentTag(node.tagName) && !context.accordionItemIsOpen) {
|
|
1069
|
+
return null;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Check for hidden classes - skip rendering unless overridden by a display class
|
|
1073
|
+
// In responsive contexts "hidden sm:flex" produces ["hidden","flex"]; the last display
|
|
1074
|
+
// class wins (same semantics as CSS), so we must not skip when a display override follows.
|
|
1075
|
+
if (classes.includes('hidden') || classes.includes('sr-only')) {
|
|
1076
|
+
const DISPLAY_OVERRIDES = new Set(['flex','inline-flex','block','inline-block','inline','grid','inline-grid','table','table-cell','table-row','contents','flow-root','list-item']);
|
|
1077
|
+
const hiddenIdx = Math.max(classes.lastIndexOf('hidden'), classes.lastIndexOf('sr-only'));
|
|
1078
|
+
const overridden = classes.slice(hiddenIdx + 1).some(c => DISPLAY_OVERRIDES.has(c));
|
|
1079
|
+
if (!overridden) {
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (node.kind === 'element' && tagLower === 'svg') {
|
|
1085
|
+
const svgString = nodeIrToSvg(node);
|
|
1086
|
+
if (svgString) {
|
|
1087
|
+
const fgColor = resolveIconForegroundColor(nextContext, colorGroup, theme);
|
|
1088
|
+
const icon = createIconFromSvg(svgString, fgColor);
|
|
1089
|
+
if (icon) {
|
|
1090
|
+
const wrapped = wrapSvgIcon(icon, classes, node.props, 'icon/svg');
|
|
1091
|
+
return maybeWrapMxAuto(wrapped, classes, context);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Image placeholder: <img> and Next.js <Image>
|
|
1097
|
+
if (tagLower === 'img' || node.tagName === 'Image') {
|
|
1098
|
+
const ir = layoutComputed.layoutIR;
|
|
1099
|
+
// Prefer Tailwind-derived fixed sizes, then explicit width/height props, then defaults
|
|
1100
|
+
const w = ir.fixedWidth
|
|
1101
|
+
?? (node.props.width != null ? parseFloat(String(node.props.width)) : null)
|
|
1102
|
+
?? 200;
|
|
1103
|
+
const h = ir.fixedHeight
|
|
1104
|
+
?? (node.props.height != null ? parseFloat(String(node.props.height)) : null)
|
|
1105
|
+
?? 150;
|
|
1106
|
+
const imgFrame = figma.createFrame();
|
|
1107
|
+
imgFrame.name = node.props.alt ? String(node.props.alt) : 'image';
|
|
1108
|
+
// layoutMode must stay NONE (fixed-size image frame); don't set sizing mode properties
|
|
1109
|
+
imgFrame.layoutMode = 'NONE';
|
|
1110
|
+
// Apply border radius from Tailwind classes (fills set below)
|
|
1111
|
+
applyTailwindStylesToFrame(imgFrame, classes, colorGroup, radiusGroup, theme);
|
|
1112
|
+
// Resize after applyTailwindStylesToFrame in case it modifies the frame
|
|
1113
|
+
imgFrame.resize(Math.max(1, w), Math.max(1, h));
|
|
1114
|
+
|
|
1115
|
+
const src = node.props.src ? String(node.props.src) : null;
|
|
1116
|
+
const svgString = src ? getSvgString(src) : null;
|
|
1117
|
+
const imageHash = src ? getImageHash(src) : null;
|
|
1118
|
+
|
|
1119
|
+
if (svgString) {
|
|
1120
|
+
// SVG: render as native vector node via createNodeFromSvg
|
|
1121
|
+
try {
|
|
1122
|
+
const svgNode = figma.createNodeFromSvg(svgString);
|
|
1123
|
+
svgNode.name = node.props.alt ? String(node.props.alt) : 'image';
|
|
1124
|
+
svgNode.resize(Math.max(1, w), Math.max(1, h));
|
|
1125
|
+
imgFrame.remove();
|
|
1126
|
+
return maybeWrapMxAuto(svgNode, classes, context);
|
|
1127
|
+
} catch (_e) { /* fall through to placeholder */ }
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
if (imageHash) {
|
|
1131
|
+
imgFrame.fills = [{ type: 'IMAGE', scaleMode: 'FILL', imageHash } as ImagePaint];
|
|
1132
|
+
} else {
|
|
1133
|
+
imgFrame.fills = [{ type: 'SOLID', color: { r: 0.878, g: 0.894, b: 0.914 }, opacity: 1 }];
|
|
1134
|
+
try {
|
|
1135
|
+
const placeholderSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`;
|
|
1136
|
+
const iconNode = figma.createNodeFromSvg(placeholderSvg);
|
|
1137
|
+
if (iconNode) {
|
|
1138
|
+
iconNode.x = Math.max(0, Math.round((w - 24) / 2));
|
|
1139
|
+
iconNode.y = Math.max(0, Math.round((h - 24) / 2));
|
|
1140
|
+
imgFrame.appendChild(iconNode);
|
|
1141
|
+
}
|
|
1142
|
+
} catch (_e) { /* createNodeFromSvg not available in all contexts */ }
|
|
1143
|
+
}
|
|
1144
|
+
return maybeWrapMxAuto(imgFrame, classes, context);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
if (node.kind === 'element') {
|
|
1148
|
+
const fgColor = resolveIconForegroundColor(nextContext, colorGroup, theme);
|
|
1149
|
+
const registryIcon = renderRegistryIcon(node.tagName, classes, node.props, fgColor);
|
|
1150
|
+
if (registryIcon) {
|
|
1151
|
+
return maybeWrapMxAuto(registryIcon, classes, context);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const mappedIcon = renderMappedIcon(
|
|
1155
|
+
node.tagName,
|
|
1156
|
+
classes,
|
|
1157
|
+
node.props,
|
|
1158
|
+
fgColor,
|
|
1159
|
+
!!nextContext.accordionItemIsOpen
|
|
1160
|
+
);
|
|
1161
|
+
if (mappedIcon) {
|
|
1162
|
+
return maybeWrapMxAuto(mappedIcon, classes, context);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Check for React component (uppercase) - try to find definition
|
|
1167
|
+
if (node.kind === 'component') {
|
|
1168
|
+
const expanded = maybeExpandTextLineComponent(node);
|
|
1169
|
+
if (expanded) {
|
|
1170
|
+
return buildFigmaNode(expanded, colorGroup, radiusGroup, theme, depth, context);
|
|
1171
|
+
}
|
|
1172
|
+
// Check if this is a React Icon we can render
|
|
1173
|
+
const fgColor = resolveIconForegroundColor(nextContext, colorGroup, theme);
|
|
1174
|
+
const registryIcon = renderRegistryIcon(node.tagName, classes, node.props, fgColor);
|
|
1175
|
+
if (registryIcon) {
|
|
1176
|
+
return maybeWrapMxAuto(registryIcon, classes, context);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
const mappedIcon = renderMappedIcon(
|
|
1180
|
+
node.tagName,
|
|
1181
|
+
classes,
|
|
1182
|
+
node.props,
|
|
1183
|
+
fgColor,
|
|
1184
|
+
!!nextContext.accordionItemIsOpen
|
|
1185
|
+
);
|
|
1186
|
+
if (mappedIcon) {
|
|
1187
|
+
return maybeWrapMxAuto(mappedIcon, classes, context);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const compDef = getComponentDefByName(node.tagName);
|
|
1191
|
+
const storyContext = getStoryBuilderContext();
|
|
1192
|
+
|
|
1193
|
+
if (compDef) {
|
|
1194
|
+
const analysis = normalizeComponentDef(compDef);
|
|
1195
|
+
if (analysis.type === 'cva') {
|
|
1196
|
+
const instance = {
|
|
1197
|
+
props: Object.assign({}, node.props),
|
|
1198
|
+
children: collectTextContent(node),
|
|
1199
|
+
};
|
|
1200
|
+
const cva = createCVAStoryInstance(analysis, instance, theme, storyContext);
|
|
1201
|
+
return maybeWrapMxAuto(cva, classes, context);
|
|
1202
|
+
}
|
|
1203
|
+
if (analysis.type === 'state') {
|
|
1204
|
+
const instance = {
|
|
1205
|
+
props: Object.assign({}, node.props),
|
|
1206
|
+
children: collectTextContent(node),
|
|
1207
|
+
};
|
|
1208
|
+
const state = createStateStoryInstance(analysis, instance, theme, storyContext);
|
|
1209
|
+
return maybeWrapMxAuto(state, classes, context);
|
|
1210
|
+
}
|
|
1211
|
+
if (!node.children || node.children.length === 0) {
|
|
1212
|
+
// For simple components used inline in jsxTree (e.g. <Box className="h-4 w-48" />),
|
|
1213
|
+
// render as a styled frame using the instance's className merged with base visual classes
|
|
1214
|
+
if (analysis.type === 'simple' && node.props.className) {
|
|
1215
|
+
const instanceClasses = node.classes.slice();
|
|
1216
|
+
const compClasses = analysis.classes || [];
|
|
1217
|
+
const baseVisualClasses = compClasses.filter((c: string) =>
|
|
1218
|
+
c.startsWith('bg-') || (c.startsWith('rounded') && !c.includes('/'))
|
|
1219
|
+
);
|
|
1220
|
+
const allClasses = mergeClasses(baseVisualClasses, instanceClasses);
|
|
1221
|
+
|
|
1222
|
+
const simpleFrame = figma.createFrame();
|
|
1223
|
+
simpleFrame.name = node.tagName;
|
|
1224
|
+
simpleFrame.layoutMode = 'VERTICAL';
|
|
1225
|
+
simpleFrame.primaryAxisSizingMode = 'FIXED';
|
|
1226
|
+
simpleFrame.counterAxisSizingMode = 'FIXED';
|
|
1227
|
+
simpleFrame.fills = [];
|
|
1228
|
+
applyClipBehavior(simpleFrame, allClasses);
|
|
1229
|
+
if (allClasses.length > 0) {
|
|
1230
|
+
applyTailwindStylesToFrame(simpleFrame, allClasses, colorGroup, radiusGroup, theme);
|
|
1231
|
+
}
|
|
1232
|
+
return maybeWrapMxAuto(simpleFrame, allClasses, context);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// If the component has a jsxTree (from its own implementation), render it directly
|
|
1236
|
+
// (e.g. <HeroSection /> inline with no children expands to the full component structure)
|
|
1237
|
+
if (analysis.type === 'simple' && analysis.jsxTree) {
|
|
1238
|
+
const result = renderComponentWithJsxTree(
|
|
1239
|
+
analysis.jsxTree,
|
|
1240
|
+
[],
|
|
1241
|
+
colorGroup,
|
|
1242
|
+
radiusGroup,
|
|
1243
|
+
theme,
|
|
1244
|
+
depth,
|
|
1245
|
+
context
|
|
1246
|
+
);
|
|
1247
|
+
if (result) {
|
|
1248
|
+
return maybeWrapMxAuto(result, classes, context);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
const frame = figma.createFrame();
|
|
1253
|
+
frame.name = node.tagName;
|
|
1254
|
+
frame.layoutMode = 'VERTICAL';
|
|
1255
|
+
frame.primaryAxisSizingMode = 'AUTO';
|
|
1256
|
+
frame.counterAxisSizingMode = 'AUTO';
|
|
1257
|
+
frame.fills = [];
|
|
1258
|
+
applyClipBehavior(frame, classes);
|
|
1259
|
+
|
|
1260
|
+
const comp = createCompoundComponent(frame, analysis, theme);
|
|
1261
|
+
if (comp) return maybeWrapMxAuto(comp, classes, context);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// If this component has children in the jsxTree, render them
|
|
1266
|
+
// (wrapper components should show their content)
|
|
1267
|
+
if (node.children && node.children.length > 0) {
|
|
1268
|
+
// Get compound classes - try own definition first, then parent's subComponents
|
|
1269
|
+
let compoundClasses: string[] = [];
|
|
1270
|
+
let currentCompoundDef: any = null;
|
|
1271
|
+
if (compDef) {
|
|
1272
|
+
const normalizedDef = normalizeComponentDef(compDef);
|
|
1273
|
+
// Get subComponent classes if this is a compound component
|
|
1274
|
+
compoundClasses = getCompoundClasses(normalizedDef, node.tagName);
|
|
1275
|
+
if (normalizedDef.type === 'compound') {
|
|
1276
|
+
currentCompoundDef = normalizedDef;
|
|
1277
|
+
}
|
|
1278
|
+
// Check if this component has a jsxTree structure (for components with decorative elements)
|
|
1279
|
+
if (normalizedDef.type === 'simple' && normalizedDef.jsxTree) {
|
|
1280
|
+
const hasInstanceChildren = (node.children?.length ?? 0) > 0;
|
|
1281
|
+
const canRenderTree = !hasInstanceChildren || jsxTreeHasChildrenSlot(normalizedDef.jsxTree);
|
|
1282
|
+
if (canRenderTree) {
|
|
1283
|
+
// Render the component's internal structure from jsxTree
|
|
1284
|
+
const result = renderComponentWithJsxTree(
|
|
1285
|
+
normalizedDef.jsxTree,
|
|
1286
|
+
node.children,
|
|
1287
|
+
colorGroup,
|
|
1288
|
+
radiusGroup,
|
|
1289
|
+
theme,
|
|
1290
|
+
depth,
|
|
1291
|
+
context
|
|
1292
|
+
);
|
|
1293
|
+
if (result) {
|
|
1294
|
+
return maybeWrapMxAuto(result, classes, context);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
// Use only container-level classes from the definition
|
|
1299
|
+
const defClasses = normalizedDef.baseClasses || normalizedDef.classes || [];
|
|
1300
|
+
if (defClasses.length > 0) {
|
|
1301
|
+
// Filter to only container classes (no decorative element classes)
|
|
1302
|
+
const containerClasses = defClasses.filter((c: string) =>
|
|
1303
|
+
c === 'relative' || c === 'isolate' ||
|
|
1304
|
+
c.startsWith('py-') || c.startsWith('px-') || c.startsWith('p-') ||
|
|
1305
|
+
c.startsWith('rounded')
|
|
1306
|
+
);
|
|
1307
|
+
compoundClasses = mergeClasses(containerClasses, compoundClasses);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
// If no classes found and we have a parent compound def, look up as subComponent
|
|
1311
|
+
if (compoundClasses.length === 0 && context.parentCompoundDef) {
|
|
1312
|
+
compoundClasses = getCompoundClasses(context.parentCompoundDef, node.tagName);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
const mergedClasses = compoundClasses.length > 0
|
|
1316
|
+
? mergeClasses(compoundClasses, classes)
|
|
1317
|
+
: classes;
|
|
1318
|
+
const activeMergedClasses = mergedClasses === classes
|
|
1319
|
+
? classes
|
|
1320
|
+
: resolveClassesForBreakpoint(mergedClasses, breakpointIndex);
|
|
1321
|
+
|
|
1322
|
+
const wrapperFrame = figma.createFrame();
|
|
1323
|
+
wrapperFrame.name = node.tagName;
|
|
1324
|
+
wrapperFrame.fills = [];
|
|
1325
|
+
|
|
1326
|
+
// Apply layout using LayoutParser IR
|
|
1327
|
+
const wrapperIR = activeMergedClasses === classes ? layoutComputed.layoutIR : LayoutParser.parseToIR(activeMergedClasses);
|
|
1328
|
+
LayoutParser.applyToFrame(wrapperFrame, wrapperIR);
|
|
1329
|
+
|
|
1330
|
+
// Apply styles to the frame
|
|
1331
|
+
if (activeMergedClasses.length > 0) {
|
|
1332
|
+
applyTailwindStylesToFrame(wrapperFrame, activeMergedClasses, colorGroup, radiusGroup, theme);
|
|
1333
|
+
}
|
|
1334
|
+
applyClipBehavior(wrapperFrame, activeMergedClasses);
|
|
1335
|
+
// Calculate content width for children (accounting for padding)
|
|
1336
|
+
// This is needed for justify-between to work in Figma SPACE_BETWEEN mode
|
|
1337
|
+
const wrapperPadding = (wrapperFrame.paddingLeft || 0) + (wrapperFrame.paddingRight || 0);
|
|
1338
|
+
const hasFixedWidth = wrapperFrame.layoutMode === 'HORIZONTAL'
|
|
1339
|
+
? wrapperFrame.primaryAxisSizingMode === 'FIXED'
|
|
1340
|
+
: wrapperFrame.counterAxisSizingMode === 'FIXED';
|
|
1341
|
+
const wrapperContentWidth = hasFixedWidth && wrapperFrame.width > 0
|
|
1342
|
+
? wrapperFrame.width - wrapperPadding
|
|
1343
|
+
: undefined;
|
|
1344
|
+
const childCtx: RenderContext = Object.assign({}, nextContext, {
|
|
1345
|
+
parentLayout: wrapperFrame.layoutMode === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL',
|
|
1346
|
+
textColor: nextContext.textColor,
|
|
1347
|
+
textColorToken: nextContext.textColorToken,
|
|
1348
|
+
maxWidth: wrapperContentWidth || context.maxWidth,
|
|
1349
|
+
parentCompoundDef: currentCompoundDef || nextContext.parentCompoundDef,
|
|
1350
|
+
});
|
|
1351
|
+
const skipWrapperFullWidth = shouldSkipFullWidthForClasses(activeMergedClasses);
|
|
1352
|
+
const accordionItemOrdinal = { value: 0 };
|
|
1353
|
+
|
|
1354
|
+
for (const child of node.children) {
|
|
1355
|
+
const perChildCtx = createPerChildRenderContext(childCtx, node, child, accordionItemOrdinal);
|
|
1356
|
+
const childNode = buildFigmaNode(child, colorGroup, radiusGroup, theme, depth + 1, perChildCtx);
|
|
1357
|
+
if (childNode) {
|
|
1358
|
+
wrapperFrame.appendChild(childNode);
|
|
1359
|
+
|
|
1360
|
+
// Apply child-specific layout properties
|
|
1361
|
+
if (isElementLikeNode(child)) {
|
|
1362
|
+
LayoutParser.applyChildProperties(childNode, child.classes, wrapperFrame);
|
|
1363
|
+
}
|
|
1364
|
+
stabilizeHorizontalStretchChild(childNode, wrapperFrame);
|
|
1365
|
+
constrainSingleHorizontalTextChild(childNode);
|
|
1366
|
+
|
|
1367
|
+
applyAbsoluteIfPossible(childNode, wrapperFrame);
|
|
1368
|
+
const fullWidthOptions = (skipWrapperFullWidth || childCtx.maxWidth != null)
|
|
1369
|
+
? { skipFullWidth: skipWrapperFullWidth, widthOverride: childCtx.maxWidth }
|
|
1370
|
+
: undefined;
|
|
1371
|
+
applyFullWidthIfPossible(childNode, wrapperFrame, fullWidthOptions);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
applyDeferredBottomPositioning(wrapperFrame);
|
|
1375
|
+
applyDeferredCenterYPositioning(wrapperFrame);
|
|
1376
|
+
// Apply grid columns after all children are added
|
|
1377
|
+
const wrapperGridWidth = resolveGridWidthOverride(wrapperFrame, childCtx.maxWidth);
|
|
1378
|
+
applyGridColumnsWithReflow(wrapperFrame, wrapperGridWidth);
|
|
1379
|
+
|
|
1380
|
+
return maybeWrapMxAuto(wrapperFrame, activeMergedClasses, context);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Components like SelectValue, Input etc. that expose a placeholder prop but have no
|
|
1384
|
+
// renderable children — render the placeholder as muted text so triggers show content.
|
|
1385
|
+
if ((!node.children || node.children.length === 0) && node.props && node.props.placeholder) {
|
|
1386
|
+
const phOpts: any = { fontSize: context.textFontSize || 14, theme, opacity: 0.4 };
|
|
1387
|
+
if (context.textFontStyle != null) phOpts.fontStyle = context.textFontStyle;
|
|
1388
|
+
else if (context.textBold != null) phOpts.bold = context.textBold;
|
|
1389
|
+
if (context.textColor) phOpts.fill = context.textColor;
|
|
1390
|
+
return createTextNode(node.props.placeholder, phOpts);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// Unknown component without children - render styled frame
|
|
1394
|
+
const compFrame = figma.createFrame();
|
|
1395
|
+
compFrame.name = node.tagName;
|
|
1396
|
+
compFrame.layoutMode = 'VERTICAL';
|
|
1397
|
+
compFrame.primaryAxisSizingMode = 'AUTO';
|
|
1398
|
+
compFrame.counterAxisSizingMode = 'AUTO';
|
|
1399
|
+
compFrame.fills = [];
|
|
1400
|
+
applyClipBehavior(compFrame, classes);
|
|
1401
|
+
if (classes.length > 0) {
|
|
1402
|
+
applyTailwindStylesToFrame(compFrame, classes, colorGroup, radiusGroup, theme);
|
|
1403
|
+
}
|
|
1404
|
+
const compChildCtx: RenderContext = Object.assign({}, nextContext, {
|
|
1405
|
+
textColor: nextContext.textColor,
|
|
1406
|
+
textColorToken: nextContext.textColorToken,
|
|
1407
|
+
maxWidth: compFrame.layoutMode === 'HORIZONTAL' ? undefined : context.maxWidth,
|
|
1408
|
+
parentLayout: compFrame.layoutMode === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL',
|
|
1409
|
+
});
|
|
1410
|
+
const skipCompFullWidth = shouldSkipFullWidthForClasses(classes);
|
|
1411
|
+
const accordionItemOrdinal = { value: 0 };
|
|
1412
|
+
for (const child of node.children || []) {
|
|
1413
|
+
const perChildCtx = createPerChildRenderContext(compChildCtx, node, child, accordionItemOrdinal);
|
|
1414
|
+
const childNode = buildFigmaNode(child, colorGroup, radiusGroup, theme, depth + 1, perChildCtx);
|
|
1415
|
+
if (childNode) {
|
|
1416
|
+
compFrame.appendChild(childNode);
|
|
1417
|
+
// Apply child layout properties based on parent context
|
|
1418
|
+
if (isElementLikeNode(child)) {
|
|
1419
|
+
LayoutParser.applyChildProperties(childNode, child.classes, compFrame);
|
|
1420
|
+
}
|
|
1421
|
+
stabilizeHorizontalStretchChild(childNode, compFrame);
|
|
1422
|
+
constrainSingleHorizontalTextChild(childNode);
|
|
1423
|
+
applyAbsoluteIfPossible(childNode, compFrame);
|
|
1424
|
+
const fullWidthOptions = (skipCompFullWidth || compChildCtx.maxWidth != null)
|
|
1425
|
+
? { skipFullWidth: skipCompFullWidth, widthOverride: compChildCtx.maxWidth }
|
|
1426
|
+
: undefined;
|
|
1427
|
+
applyFullWidthIfPossible(childNode, compFrame, fullWidthOptions);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
applyDeferredBottomPositioning(compFrame);
|
|
1431
|
+
applyDeferredCenterYPositioning(compFrame);
|
|
1432
|
+
// Apply grid columns after all children are added
|
|
1433
|
+
applyGridColumnsWithReflow(compFrame);
|
|
1434
|
+
return maybeWrapMxAuto(compFrame, classes, context);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// HTML elements - create frame or text
|
|
1438
|
+
if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'span', 'a', 'label', 'li', 'small'].includes(tagLower)) {
|
|
1439
|
+
const hasElementChildren = (node.children || []).some(child => isElementLikeNode(child));
|
|
1440
|
+
const hasLayoutClass = layoutComputed.hasLayoutClass;
|
|
1441
|
+
const hasFlexChildClass = layoutComputed.hasFlexChildClass;
|
|
1442
|
+
const hasExplicitSize = layoutComputed.hasExplicitSize;
|
|
1443
|
+
const inlineOnlyChildren = hasElementChildren && (node.children || []).every(isInlineTextNode);
|
|
1444
|
+
// Any solid background on a text-category element requires a frame to be visible
|
|
1445
|
+
const hasBgClass = classes.some(cls => {
|
|
1446
|
+
if (!cls.startsWith('bg-')) return false;
|
|
1447
|
+
const rest = cls.slice(3);
|
|
1448
|
+
return !rest.startsWith('gradient') && !rest.startsWith('linear') && !rest.startsWith('radial') && rest !== 'transparent';
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
if (inlineOnlyChildren && !hasLayoutClass && !hasFlexChildClass && !hasBgClass) {
|
|
1452
|
+
const inlineText = createInlineTextNode(node as NodeIRElement, textStyle, nextContext, colorGroup, theme, layoutComputed);
|
|
1453
|
+
if (inlineText) return maybeWrapMxAuto(inlineText, classes, context);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// Create a frame container for elements with layout, explicit sizing, flex child classes, or a background fill
|
|
1457
|
+
if (hasElementChildren || hasLayoutClass || hasFlexChildClass || hasExplicitSize || hasBgClass) {
|
|
1458
|
+
const frame = figma.createFrame();
|
|
1459
|
+
frame.name = node.tagName;
|
|
1460
|
+
frame.fills = [];
|
|
1461
|
+
|
|
1462
|
+
// Apply layout using LayoutParser IR
|
|
1463
|
+
LayoutParser.applyToFrame(frame, layoutComputed.layoutIR);
|
|
1464
|
+
|
|
1465
|
+
// Apply visual styles
|
|
1466
|
+
if (classes.length > 0) {
|
|
1467
|
+
applyTailwindStylesToFrame(frame, classes, colorGroup, radiusGroup, theme);
|
|
1468
|
+
}
|
|
1469
|
+
applyClipBehavior(frame, classes);
|
|
1470
|
+
if (
|
|
1471
|
+
context.parentLayout === 'VERTICAL'
|
|
1472
|
+
&& shouldStretchToParentWidth(tagLower, classes)
|
|
1473
|
+
) {
|
|
1474
|
+
(frame as any).layoutAlign = 'STRETCH';
|
|
1475
|
+
} else if (context.parentLayout === 'VERTICAL') {
|
|
1476
|
+
// Inline display elements (inline-flex, inline-block, etc.) must not stretch to parent width.
|
|
1477
|
+
// Figma's default layoutAlign 'INHERIT' = STRETCH in a VERTICAL parent — override it.
|
|
1478
|
+
const hasInlineDisplay = classes.some(c => {
|
|
1479
|
+
const base = getBaseClass(c);
|
|
1480
|
+
return base === 'inline' || base === 'inline-flex' || base === 'inline-block' || base === 'inline-grid';
|
|
1481
|
+
});
|
|
1482
|
+
if (hasInlineDisplay) {
|
|
1483
|
+
try { (frame as any).layoutSizingHorizontal = 'HUG'; } catch (_e) { /* ignore if frame has no auto-layout */ }
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
if (
|
|
1487
|
+
frame.layoutMode === 'HORIZONTAL'
|
|
1488
|
+
&& context.maxWidth
|
|
1489
|
+
&& (frame as any).layoutAlign === 'STRETCH'
|
|
1490
|
+
&& frame.primaryAxisSizingMode !== 'FIXED'
|
|
1491
|
+
) {
|
|
1492
|
+
frame.resize(context.maxWidth, frame.height);
|
|
1493
|
+
frame.primaryAxisSizingMode = 'FIXED';
|
|
1494
|
+
}
|
|
1495
|
+
// Handle text content or children
|
|
1496
|
+
if (hasElementChildren) {
|
|
1497
|
+
const frameHasFixedWidth = frame.layoutMode === 'HORIZONTAL'
|
|
1498
|
+
? frame.primaryAxisSizingMode === 'FIXED'
|
|
1499
|
+
: frame.counterAxisSizingMode === 'FIXED';
|
|
1500
|
+
const resolvedMaxWidth = frameHasFixedWidth
|
|
1501
|
+
? (nextContext.maxWidth ? Math.min(nextContext.maxWidth, frame.width) : frame.width)
|
|
1502
|
+
: nextContext.maxWidth;
|
|
1503
|
+
const skipElementFullWidth = shouldSkipFullWidthForClasses(classes);
|
|
1504
|
+
const contentWidth = resolvedMaxWidth
|
|
1505
|
+
? Math.max(0, resolvedMaxWidth - (frame.paddingLeft || 0) - (frame.paddingRight || 0))
|
|
1506
|
+
: undefined;
|
|
1507
|
+
const gridWidthOverride = resolveGridWidthOverride(frame, contentWidth);
|
|
1508
|
+
const inheritedChildMaxWidth = frame.layoutMode === 'HORIZONTAL' ? undefined : contentWidth;
|
|
1509
|
+
const childContext: RenderContext = {
|
|
1510
|
+
...nextContext,
|
|
1511
|
+
textAlign: nextContext.textAlign,
|
|
1512
|
+
maxWidth: inheritedChildMaxWidth,
|
|
1513
|
+
parentLayout: frame.layoutMode === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL',
|
|
1514
|
+
textColor: nextContext.textColor,
|
|
1515
|
+
textColorToken: nextContext.textColorToken,
|
|
1516
|
+
};
|
|
1517
|
+
const accordionItemOrdinal = { value: 0 };
|
|
1518
|
+
for (const child of node.children || []) {
|
|
1519
|
+
const perChildCtx = createPerChildRenderContext(childContext, node, child, accordionItemOrdinal);
|
|
1520
|
+
const childNode = buildFigmaNode(child, colorGroup, radiusGroup, theme, depth + 1, perChildCtx);
|
|
1521
|
+
if (childNode) {
|
|
1522
|
+
frame.appendChild(childNode);
|
|
1523
|
+
// Apply child layout properties based on parent context
|
|
1524
|
+
if (isElementLikeNode(child)) {
|
|
1525
|
+
LayoutParser.applyChildProperties(childNode, child.classes, frame);
|
|
1526
|
+
}
|
|
1527
|
+
stabilizeHorizontalStretchChild(childNode, frame);
|
|
1528
|
+
constrainSingleHorizontalTextChild(childNode);
|
|
1529
|
+
applyAbsoluteIfPossible(childNode, frame);
|
|
1530
|
+
const fullWidthOptions = (skipElementFullWidth || childContext.maxWidth != null)
|
|
1531
|
+
? { skipFullWidth: skipElementFullWidth, widthOverride: childContext.maxWidth }
|
|
1532
|
+
: undefined;
|
|
1533
|
+
applyFullWidthIfPossible(childNode, frame, fullWidthOptions);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
applyDeferredBottomPositioning(frame);
|
|
1537
|
+
applyDeferredCenterYPositioning(frame);
|
|
1538
|
+
constrainSingleHorizontalTextChild(frame);
|
|
1539
|
+
// Apply grid columns after all children are added
|
|
1540
|
+
applyGridColumnsWithReflow(frame, gridWidthOverride);
|
|
1541
|
+
} else {
|
|
1542
|
+
// Text-only element with sizing - create text node and append
|
|
1543
|
+
const textContent = collectTextContent(node);
|
|
1544
|
+
if (textContent.trim()) {
|
|
1545
|
+
const typo = TAG_TYPOGRAPHY[tagLower] || { fontSize: 14 };
|
|
1546
|
+
const isHeadingTag = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagLower);
|
|
1547
|
+
const textOpts: any = {
|
|
1548
|
+
fontSize: typo.fontSize,
|
|
1549
|
+
bold: typo.bold,
|
|
1550
|
+
lineHeight: typo.lineHeight,
|
|
1551
|
+
theme,
|
|
1552
|
+
fontRole: isHeadingTag ? 'heading' : 'sans',
|
|
1553
|
+
};
|
|
1554
|
+
if (textStyle.text) {
|
|
1555
|
+
textOpts.fill = textStyle.text;
|
|
1556
|
+
} else if (nextContext.textColor) {
|
|
1557
|
+
textOpts.fill = nextContext.textColor;
|
|
1558
|
+
}
|
|
1559
|
+
const resolvedBold = getResolvedBoldFromClasses(classes, textOpts.bold);
|
|
1560
|
+
if (resolvedBold != null) textOpts.bold = resolvedBold;
|
|
1561
|
+
const resolvedFontStyle = getFontStyleFromClasses(classes, nextContext.textFontStyle);
|
|
1562
|
+
if (resolvedFontStyle != null) textOpts.fontStyle = resolvedFontStyle;
|
|
1563
|
+
const resolvedSize = getResolvedTextSizeFromClasses(classes, textOpts.fontSize);
|
|
1564
|
+
if (resolvedSize != null) textOpts.fontSize = resolvedSize;
|
|
1565
|
+
// Apply text-center alignment if present
|
|
1566
|
+
if (classes.includes('text-center')) {
|
|
1567
|
+
textOpts.textAlignHorizontal = 'CENTER';
|
|
1568
|
+
} else if (classes.includes('text-right')) {
|
|
1569
|
+
textOpts.textAlignHorizontal = 'RIGHT';
|
|
1570
|
+
}
|
|
1571
|
+
const textNode = createTextNode(textContent, textOpts);
|
|
1572
|
+
// In a HORIZONTAL frame with CENTER alignment, don't force the text node
|
|
1573
|
+
// to a fixed width — let it stay HUG-sized so the frame's CENTER alignment
|
|
1574
|
+
// can position it (e.g. a numbered circle with justify-center).
|
|
1575
|
+
const isHorizontalCentered = frame.layoutMode === 'HORIZONTAL'
|
|
1576
|
+
&& (frame as any).primaryAxisAlignItems === 'CENTER';
|
|
1577
|
+
const textContentWidth = (!isHorizontalCentered && nextContext.maxWidth)
|
|
1578
|
+
? Math.max(0, nextContext.maxWidth - (frame.paddingLeft || 0) - (frame.paddingRight || 0))
|
|
1579
|
+
: undefined;
|
|
1580
|
+
if (textContentWidth && textContentWidth > 0) {
|
|
1581
|
+
try {
|
|
1582
|
+
textNode.textAutoResize = 'HEIGHT';
|
|
1583
|
+
textNode.resize(textContentWidth, textNode.height);
|
|
1584
|
+
} catch (_err) {
|
|
1585
|
+
// ignore resize errors
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
frame.appendChild(textNode);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
return maybeWrapMxAuto(frame, classes, context);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
// Text-like elements - gather all text content
|
|
1595
|
+
const textContent = collectTextContent(node);
|
|
1596
|
+
if (!textContent.trim()) return null;
|
|
1597
|
+
|
|
1598
|
+
const typo = TAG_TYPOGRAPHY[tagLower] || { fontSize: 14 };
|
|
1599
|
+
const isHeadingTag = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagLower);
|
|
1600
|
+
const textOpts: any = {
|
|
1601
|
+
fontSize: typo.fontSize,
|
|
1602
|
+
bold: typo.bold,
|
|
1603
|
+
lineHeight: typo.lineHeight,
|
|
1604
|
+
theme,
|
|
1605
|
+
fontRole: isHeadingTag ? 'heading' : 'sans',
|
|
1606
|
+
};
|
|
1607
|
+
|
|
1608
|
+
// Extract text color from classes
|
|
1609
|
+
if (textStyle.text) {
|
|
1610
|
+
textOpts.fill = textStyle.text;
|
|
1611
|
+
} else if (nextContext.textColor) {
|
|
1612
|
+
textOpts.fill = nextContext.textColor;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
if (nextContext.textAlign) {
|
|
1616
|
+
textOpts.textAlignHorizontal = nextContext.textAlign;
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
// Check for bold/font-weight class
|
|
1620
|
+
const resolvedBold = getResolvedBoldFromClasses(classes, textOpts.bold);
|
|
1621
|
+
if (resolvedBold != null) textOpts.bold = resolvedBold;
|
|
1622
|
+
const resolvedFontStyle = getFontStyleFromClasses(classes, nextContext.textFontStyle);
|
|
1623
|
+
if (resolvedFontStyle != null) textOpts.fontStyle = resolvedFontStyle;
|
|
1624
|
+
|
|
1625
|
+
// Extract font size from classes
|
|
1626
|
+
const resolvedSize = getResolvedTextSizeFromClasses(classes, textOpts.fontSize);
|
|
1627
|
+
if (resolvedSize != null) textOpts.fontSize = resolvedSize;
|
|
1628
|
+
|
|
1629
|
+
const lineHeight = getLineHeightFromClasses(classes, textOpts.fontSize);
|
|
1630
|
+
if (lineHeight) {
|
|
1631
|
+
textOpts.lineHeight = lineHeight;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
const textNode = createTextNode(textContent, textOpts);
|
|
1635
|
+
const isBlockText = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p'].includes(tagLower);
|
|
1636
|
+
const shouldConstrain = nextContext.maxWidth
|
|
1637
|
+
&& nextContext.parentLayout !== 'HORIZONTAL'
|
|
1638
|
+
&& (isBlockText || classes.includes('block') || classes.includes('w-full'));
|
|
1639
|
+
const hasTruncateClass = classes.includes('truncate');
|
|
1640
|
+
let lineClampN: number | null = null;
|
|
1641
|
+
for (const cls of classes) {
|
|
1642
|
+
const m = cls.match(/^line-clamp-(\d+)$/);
|
|
1643
|
+
if (m) { lineClampN = parseInt(m[1], 10); break; }
|
|
1644
|
+
}
|
|
1645
|
+
if ((hasTruncateClass || lineClampN != null) && nextContext.maxWidth) {
|
|
1646
|
+
try {
|
|
1647
|
+
const maxLines = lineClampN ?? 1;
|
|
1648
|
+
const fs = textOpts.fontSize || 14;
|
|
1649
|
+
const lh = textOpts.lineHeight || Math.ceil(fs * 1.5);
|
|
1650
|
+
(textNode as any).textTruncation = 'ENDING';
|
|
1651
|
+
(textNode as any).maxLines = maxLines;
|
|
1652
|
+
textNode.resize(nextContext.maxWidth, Math.max(lh, lh * maxLines));
|
|
1653
|
+
} catch (_err) { /* ignore */ }
|
|
1654
|
+
} else if (shouldConstrain) {
|
|
1655
|
+
try {
|
|
1656
|
+
textNode.textAutoResize = 'HEIGHT';
|
|
1657
|
+
textNode.resize(nextContext.maxWidth, textNode.height);
|
|
1658
|
+
} catch (_err) {
|
|
1659
|
+
// ignore resize errors
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
return maybeWrapMxAuto(textNode, classes, context);
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// <hr> - horizontal rule / separator
|
|
1666
|
+
if (tagLower === 'hr') {
|
|
1667
|
+
const hr = figma.createFrame();
|
|
1668
|
+
hr.name = 'hr';
|
|
1669
|
+
hr.layoutMode = 'HORIZONTAL';
|
|
1670
|
+
hr.resize(200, 1);
|
|
1671
|
+
hr.primaryAxisSizingMode = 'FIXED';
|
|
1672
|
+
hr.counterAxisSizingMode = 'FIXED';
|
|
1673
|
+
const borderColor = colorGroup.border ? parseColor(colorGroup.border) : { r: 0.85, g: 0.85, b: 0.85 };
|
|
1674
|
+
hr.fills = [{ type: 'SOLID', color: { r: borderColor.r, g: borderColor.g, b: borderColor.b } }];
|
|
1675
|
+
if (classes.includes('w-full')) {
|
|
1676
|
+
markFullWidthNode(hr);
|
|
1677
|
+
}
|
|
1678
|
+
return maybeWrapMxAuto(hr, classes, context);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// Container elements (div, nav, section, header, footer, ul, ol, etc.)
|
|
1682
|
+
|
|
1683
|
+
// Check for clip-path in style prop - create vector shape instead of frame
|
|
1684
|
+
const clipPathPolygon = parseClipPathFromStyle(node.props.style);
|
|
1685
|
+
if (clipPathPolygon && node.children.length === 0) {
|
|
1686
|
+
const decorativeNode = buildDecorativeClipPathNode({
|
|
1687
|
+
clipPathPolygon,
|
|
1688
|
+
classes,
|
|
1689
|
+
contextMaxWidth: context.maxWidth,
|
|
1690
|
+
colorGroup,
|
|
1691
|
+
radiusGroup,
|
|
1692
|
+
theme,
|
|
1693
|
+
});
|
|
1694
|
+
if (decorativeNode) return maybeWrapMxAuto(decorativeNode, classes, context);
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
const frame = figma.createFrame();
|
|
1698
|
+
frame.name = node.tagName;
|
|
1699
|
+
frame.fills = [];
|
|
1700
|
+
|
|
1701
|
+
// Apply layout using LayoutParser IR (handles flex, grid, alignment, spacing, dimensions)
|
|
1702
|
+
LayoutParser.applyToFrame(frame, layoutComputed.layoutIR);
|
|
1703
|
+
|
|
1704
|
+
// Apply visual styles (colors, borders, radius) from Tailwind classes
|
|
1705
|
+
// Resolve sm: breakpoint overrides when container is >= 640px (matches CSS cascade)
|
|
1706
|
+
const effectiveClasses = (context.maxWidth != null && context.maxWidth >= 640)
|
|
1707
|
+
? getClassesForBreakpoint(classes, 'sm')
|
|
1708
|
+
: classes;
|
|
1709
|
+
if (effectiveClasses.length > 0) {
|
|
1710
|
+
applyTailwindStylesToFrame(frame, effectiveClasses, colorGroup, radiusGroup, theme);
|
|
1711
|
+
}
|
|
1712
|
+
applyClipBehavior(frame, effectiveClasses);
|
|
1713
|
+
// Hero-like containers (`relative isolate`) with decorative clip-path blobs should
|
|
1714
|
+
// clip to their own bounds so gradients never spill outside the component frame.
|
|
1715
|
+
if (effectiveClasses.includes('relative') && effectiveClasses.includes('isolate') && nodeHasClipPathDescendant(node)) {
|
|
1716
|
+
frame.clipsContent = true;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// NOTE: text-left/center/right only affects TEXT content in CSS, not block layout.
|
|
1720
|
+
// Do NOT apply alignFromClasses to frame.counterAxisAlignItems here.
|
|
1721
|
+
// The textAlign context is propagated to child text nodes instead.
|
|
1722
|
+
|
|
1723
|
+
const shouldApplyContextWidth = depth === 0 && context.maxWidth;
|
|
1724
|
+
if (shouldApplyContextWidth || (nextContext.maxWidth && nextContext.maxWidth !== context.maxWidth)) {
|
|
1725
|
+
const targetWidth = shouldApplyContextWidth ? context.maxWidth : nextContext.maxWidth;
|
|
1726
|
+
if (targetWidth) {
|
|
1727
|
+
if (frame.layoutMode === 'HORIZONTAL') {
|
|
1728
|
+
frame.resize(targetWidth, frame.height);
|
|
1729
|
+
frame.primaryAxisSizingMode = 'FIXED';
|
|
1730
|
+
} else {
|
|
1731
|
+
frame.resize(targetWidth, frame.height);
|
|
1732
|
+
frame.counterAxisSizingMode = 'FIXED';
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
// Clip the story root frame so absolute-positioned children (e.g. gradient blobs)
|
|
1736
|
+
// don't extend visually outside the component's bounds in Figma.
|
|
1737
|
+
if (shouldApplyContextWidth) {
|
|
1738
|
+
frame.clipsContent = true;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// Block-level elements in VERTICAL parents should stretch to fill width
|
|
1743
|
+
// Note: We set layoutAlign even before the frame is appended - Figma will respect it when appended
|
|
1744
|
+
if (
|
|
1745
|
+
context.parentLayout === 'VERTICAL'
|
|
1746
|
+
&& shouldStretchToParentWidth(tagLower, classes)
|
|
1747
|
+
) {
|
|
1748
|
+
(frame as any).layoutAlign = 'STRETCH';
|
|
1749
|
+
} else if (context.parentLayout === 'VERTICAL') {
|
|
1750
|
+
const hasInlineDisplay = classes.some(c => {
|
|
1751
|
+
const base = getBaseClass(c);
|
|
1752
|
+
return base === 'inline' || base === 'inline-flex' || base === 'inline-block' || base === 'inline-grid';
|
|
1753
|
+
});
|
|
1754
|
+
if (hasInlineDisplay) {
|
|
1755
|
+
try { (frame as any).layoutSizingHorizontal = 'HUG'; } catch (_e) { /* ignore if frame has no auto-layout */ }
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
if (
|
|
1760
|
+
frame.layoutMode === 'HORIZONTAL'
|
|
1761
|
+
&& context.maxWidth
|
|
1762
|
+
&& (frame as any).layoutAlign === 'STRETCH'
|
|
1763
|
+
&& frame.primaryAxisSizingMode !== 'FIXED'
|
|
1764
|
+
) {
|
|
1765
|
+
frame.resize(context.maxWidth, frame.height);
|
|
1766
|
+
frame.primaryAxisSizingMode = 'FIXED';
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
const frameHasFixedWidth = frame.layoutMode === 'HORIZONTAL'
|
|
1770
|
+
? frame.primaryAxisSizingMode === 'FIXED'
|
|
1771
|
+
: frame.counterAxisSizingMode === 'FIXED';
|
|
1772
|
+
const resolvedMaxWidth = frameHasFixedWidth
|
|
1773
|
+
? (nextContext.maxWidth ? Math.min(nextContext.maxWidth, frame.width) : frame.width)
|
|
1774
|
+
: nextContext.maxWidth;
|
|
1775
|
+
const gridCols = extractGridColumns(classes, resolvedMaxWidth || context.maxWidth);
|
|
1776
|
+
const gridWidth = resolvedMaxWidth || context.maxWidth || (gridCols ? defaultGridWidth(gridCols) : undefined);
|
|
1777
|
+
const skipChildFullWidth = !!gridCols || classes.includes('grid') || classes.includes('inline-grid');
|
|
1778
|
+
if (gridCols && gridWidth && 'resize' in frame) {
|
|
1779
|
+
try {
|
|
1780
|
+
if (frame.layoutMode !== 'HORIZONTAL') frame.layoutMode = 'HORIZONTAL';
|
|
1781
|
+
if ((frame as any).layoutWrap !== undefined) {
|
|
1782
|
+
(frame as any).layoutWrap = 'WRAP';
|
|
1783
|
+
}
|
|
1784
|
+
frame.resize(gridWidth, frame.height);
|
|
1785
|
+
frame.primaryAxisSizingMode = 'FIXED';
|
|
1786
|
+
frame.counterAxisSizingMode = 'AUTO';
|
|
1787
|
+
} catch (_err) {
|
|
1788
|
+
// ignore resize errors
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// Render children
|
|
1793
|
+
const contentWidth = resolvedMaxWidth
|
|
1794
|
+
? Math.max(0, resolvedMaxWidth - (frame.paddingLeft || 0) - (frame.paddingRight || 0))
|
|
1795
|
+
: undefined;
|
|
1796
|
+
const inheritedChildMaxWidth = frame.layoutMode === 'HORIZONTAL' ? undefined : contentWidth;
|
|
1797
|
+
const childContext: RenderContext = {
|
|
1798
|
+
...nextContext,
|
|
1799
|
+
textAlign: nextContext.textAlign,
|
|
1800
|
+
maxWidth: inheritedChildMaxWidth,
|
|
1801
|
+
parentLayout: frame.layoutMode === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL',
|
|
1802
|
+
textColor: nextContext.textColor,
|
|
1803
|
+
textColorToken: nextContext.textColorToken,
|
|
1804
|
+
};
|
|
1805
|
+
const renderChildren = getRenderableChildren(node, classes);
|
|
1806
|
+
const accordionItemOrdinal = { value: 0 };
|
|
1807
|
+
|
|
1808
|
+
// For VERTICAL layouts, extract mt-* (margin-top) from children and use as itemSpacing
|
|
1809
|
+
// This converts CSS margins to Figma auto-layout gaps
|
|
1810
|
+
if (frame.layoutMode === 'VERTICAL' && renderChildren.length > 1 && !isSemanticTableContainer(node)) {
|
|
1811
|
+
let maxMarginTop = 0;
|
|
1812
|
+
for (const child of renderChildren) {
|
|
1813
|
+
if (!isElementLikeNode(child)) continue;
|
|
1814
|
+
const childClasses = child.classes;
|
|
1815
|
+
for (const cls of childClasses) {
|
|
1816
|
+
const mtMatch = cls.match(/^mt-(\d+)$/);
|
|
1817
|
+
if (mtMatch) {
|
|
1818
|
+
const mtValue = parseInt(mtMatch[1], 10) * 4; // Tailwind spacing scale
|
|
1819
|
+
if (mtValue > maxMarginTop) maxMarginTop = mtValue;
|
|
1820
|
+
}
|
|
1821
|
+
// Also handle mt-[Xpx] arbitrary values
|
|
1822
|
+
const mtArbitraryMatch = cls.match(/^mt-\[(\d+)px\]$/);
|
|
1823
|
+
if (mtArbitraryMatch) {
|
|
1824
|
+
const mtValue = parseInt(mtArbitraryMatch[1], 10);
|
|
1825
|
+
if (mtValue > maxMarginTop) maxMarginTop = mtValue;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
if (maxMarginTop > 0 && (!frame.itemSpacing || frame.itemSpacing < maxMarginTop)) {
|
|
1830
|
+
frame.itemSpacing = maxMarginTop;
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// Inline-only div: all children are text/span/a nodes (no block layout).
|
|
1835
|
+
// Collapse into a single text node — like a CSS inline container (e.g. pill labels).
|
|
1836
|
+
const hasOnlyInlineChildren = !layoutComputed.hasLayoutClass
|
|
1837
|
+
&& renderChildren.length > 0
|
|
1838
|
+
&& renderChildren.every(isInlineTextNode);
|
|
1839
|
+
if (hasOnlyInlineChildren) {
|
|
1840
|
+
const inlineText = createInlineTextNode(node as any, textStyle, nextContext, colorGroup, theme, layoutComputed);
|
|
1841
|
+
if (inlineText) {
|
|
1842
|
+
frame.appendChild(inlineText);
|
|
1843
|
+
return maybeWrapMxAuto(frame, classes, context);
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
for (const child of renderChildren) {
|
|
1848
|
+
const perChildCtx = createPerChildRenderContext(childContext, node, child, accordionItemOrdinal);
|
|
1849
|
+
const childNode = buildFigmaNode(child, colorGroup, radiusGroup, theme, depth + 1, perChildCtx);
|
|
1850
|
+
if (childNode) {
|
|
1851
|
+
frame.appendChild(childNode);
|
|
1852
|
+
|
|
1853
|
+
// Apply child-specific layout properties (flex-1, self-*, etc.)
|
|
1854
|
+
if (isElementLikeNode(child)) {
|
|
1855
|
+
LayoutParser.applyChildProperties(childNode, child.classes, frame);
|
|
1856
|
+
}
|
|
1857
|
+
stabilizeHorizontalStretchChild(childNode, frame);
|
|
1858
|
+
constrainSingleHorizontalTextChild(childNode);
|
|
1859
|
+
|
|
1860
|
+
applyAbsoluteIfPossible(childNode, frame);
|
|
1861
|
+
const fullWidthOptions = (skipChildFullWidth || contentWidth != null)
|
|
1862
|
+
? { skipFullWidth: skipChildFullWidth, widthOverride: contentWidth }
|
|
1863
|
+
: undefined;
|
|
1864
|
+
applyFullWidthIfPossible(childNode, frame, fullWidthOptions);
|
|
1865
|
+
if (
|
|
1866
|
+
node.kind === 'element'
|
|
1867
|
+
&& node.tagLower === 'table'
|
|
1868
|
+
&& child.kind === 'element'
|
|
1869
|
+
&& child.tagLower === 'tfoot'
|
|
1870
|
+
) {
|
|
1871
|
+
// CSS table border-collapse merges adjacent borders to 1px.
|
|
1872
|
+
// In Figma stacked frames accumulate strokes, so clear tfoot top stroke.
|
|
1873
|
+
clearTopBorder(childNode);
|
|
1874
|
+
}
|
|
1875
|
+
if (
|
|
1876
|
+
node.kind === 'element'
|
|
1877
|
+
&& node.tagLower === 'tfoot'
|
|
1878
|
+
&& child.kind === 'element'
|
|
1879
|
+
&& child.tagLower === 'tr'
|
|
1880
|
+
) {
|
|
1881
|
+
// Matches [&>tr]:last:border-b-0 on TableFooter.
|
|
1882
|
+
clearBottomBorder(childNode);
|
|
1883
|
+
}
|
|
1884
|
+
if (
|
|
1885
|
+
node.kind === 'element'
|
|
1886
|
+
&& node.tagLower === 'tr'
|
|
1887
|
+
&& (child.kind === 'element' || child.kind === 'component')
|
|
1888
|
+
&& (child.tagLower === 'td' || child.tagLower === 'th')
|
|
1889
|
+
&& !hasTableCellSizeOverride(child.classes)
|
|
1890
|
+
) {
|
|
1891
|
+
const colSpan = getNumericColSpan((child.props as any)?.colSpan);
|
|
1892
|
+
try {
|
|
1893
|
+
(childNode as any).layoutGrow = colSpan;
|
|
1894
|
+
} catch (_err) {
|
|
1895
|
+
// ignore if node cannot grow in this parent
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
// For input/textarea elements with no children, render defaultValue or placeholder as text
|
|
1902
|
+
if ((tagLower === 'input' || tagLower === 'textarea') && (!node.children || node.children.length === 0)) {
|
|
1903
|
+
const inputText = node.props.defaultValue || node.props.value;
|
|
1904
|
+
const inputPlaceholder = node.props.placeholder;
|
|
1905
|
+
if (inputText || inputPlaceholder) {
|
|
1906
|
+
const inputTextOpts: any = { fontSize: context.textFontSize || 14, theme };
|
|
1907
|
+
if (inputText) {
|
|
1908
|
+
if (context.textColor) inputTextOpts.fill = context.textColor;
|
|
1909
|
+
} else {
|
|
1910
|
+
inputTextOpts.opacity = 0.4; // placeholder muted
|
|
1911
|
+
}
|
|
1912
|
+
const inputTextNode = createTextNode(inputText || inputPlaceholder, inputTextOpts);
|
|
1913
|
+
frame.appendChild(inputTextNode);
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
applyDeferredBottomPositioning(frame);
|
|
1918
|
+
applyDeferredCenterYPositioning(frame);
|
|
1919
|
+
applyGridColumnsWithReflow(frame, gridWidth || frame.width, gridCols || undefined);
|
|
1920
|
+
|
|
1921
|
+
// Re-apply clipping after children are added, in case Figma resets it during layout ops.
|
|
1922
|
+
if (shouldClipContent(classes)) {
|
|
1923
|
+
frame.clipsContent = true;
|
|
1924
|
+
}
|
|
1925
|
+
// Re-apply directional border widths after layout/resize passes; Figma may normalize
|
|
1926
|
+
// stroke sides during downstream sizing operations.
|
|
1927
|
+
if (Array.isArray(frame.strokes) && frame.strokes.length > 0) {
|
|
1928
|
+
applyBorderWidthUtilities(frame, classes);
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
return maybeWrapMxAuto(frame, classes, context);
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
/**
|
|
1935
|
+
* Recursively collect text content from a resolved node tree
|
|
1936
|
+
*/
|
|
1937
|
+
function collectTextContent(node: NodeIR): string {
|
|
1938
|
+
const parts: string[] = [];
|
|
1939
|
+
const walk = (current: NodeIR): void => {
|
|
1940
|
+
if (current.kind === 'text') {
|
|
1941
|
+
parts.push(current.text);
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
if (current.kind === 'fragment') {
|
|
1945
|
+
for (const child of current.children) {
|
|
1946
|
+
walk(child);
|
|
1947
|
+
}
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
if (current.kind === 'divider') {
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1953
|
+
if (current.kind === 'ring') {
|
|
1954
|
+
walk(current.child);
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
if (current.classes.includes('hidden') || current.classes.includes('sr-only')) {
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
for (const child of current.children || []) {
|
|
1961
|
+
walk(child);
|
|
1962
|
+
}
|
|
1963
|
+
};
|
|
1964
|
+
walk(node);
|
|
1965
|
+
return parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
function applyLayoutClasses(
|
|
1969
|
+
frame: any,
|
|
1970
|
+
classes: string[] | undefined,
|
|
1971
|
+
colorGroup: Record<string, string>,
|
|
1972
|
+
radiusGroup: Record<string, string> | null,
|
|
1973
|
+
theme: string
|
|
1974
|
+
): void {
|
|
1975
|
+
const list = classes || [];
|
|
1976
|
+
const layoutIR = LayoutParser.parseToIR(list);
|
|
1977
|
+
LayoutParser.applyToFrame(frame, layoutIR);
|
|
1978
|
+
frame.fills = frame.fills || [];
|
|
1979
|
+
if (list.length > 0) {
|
|
1980
|
+
applyTailwindStylesToFrame(frame, list, colorGroup, radiusGroup, theme);
|
|
1981
|
+
}
|
|
1982
|
+
applyClipBehavior(frame, list);
|
|
1983
|
+
|
|
1984
|
+
if (!frame.itemSpacing || frame.itemSpacing === 0) {
|
|
1985
|
+
frame.itemSpacing = 12;
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
function applyAbsoluteIfAllowed(child: SceneNode, parent: FrameNode, allowAbsolute: boolean): void {
|
|
1990
|
+
if (!allowAbsolute) return;
|
|
1991
|
+
applyAbsoluteIfPossible(child, parent);
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
export function createUIComponents(parent: any, opts?: any): any {
|
|
1995
|
+
return createUIComponentsFromStory(parent, opts, getStoryBuilderContext());
|
|
1996
|
+
}
|