Skip to content

Commit 709fecc

Browse files
committed
feat: enhance custom property handling, variable transforms, and code generation
- Improve custom property handling in code generation and markup transforms - Add utility for consistent raw variable name retrieval - Extend Tailwind width and height keyword support - Refine text rendering for invisible text and style merging - Update variable collection with rewrites to improve token resolution
1 parent 5caa989 commit 709fecc

File tree

13 files changed

+722
-97
lines changed

13 files changed

+722
-97
lines changed

packages/extension/mcp/tools/code/assets/vector.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export async function exportSvgEntry(
2323
svgString = transformSvgAttributes(svgString, config)
2424

2525
const baseProps = extractSvgAttributes(svgString)
26+
if (!Object.keys(baseProps).length) {
27+
// If we failed to parse attributes, inline the SVG to avoid emitting an empty tag.
28+
return { props: {}, raw: svgString }
29+
}
2630
try {
2731
const asset = await ensureAssetUploaded(svgUint8, 'image/svg+xml', {
2832
width: Math.round(node.width),

packages/extension/mcp/tools/code/collect.ts

Lines changed: 212 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,22 @@ import type { AssetDescriptor } from '@tempad-dev/mcp-shared'
33
import type { SemanticNode } from '@/mcp/semantic-tree'
44
import type { CodegenConfig } from '@/utils/codegen'
55

6+
import { toDecimalPlace } from '@/utils/number'
7+
68
import type { SvgEntry } from './assets'
79

810
import { exportSvgEntry, hasImageFills, replaceImageUrlsWithAssets } from './assets'
911
import { preprocessStyles, stripInertShadows } from './style'
1012

13+
const VECTOR_LIKE_TYPES = new Set<SceneNode['type']>([
14+
'VECTOR',
15+
'BOOLEAN_OPERATION',
16+
'STAR',
17+
'LINE',
18+
'ELLIPSE',
19+
'POLYGON'
20+
])
21+
1122
export type CollectedSceneData = {
1223
nodes: Map<string, SceneNode>
1324
styles: Map<string, Record<string, string>>
@@ -20,20 +31,47 @@ export async function collectSceneData(
2031
assetRegistry: Map<string, AssetDescriptor>
2132
): Promise<CollectedSceneData> {
2233
const semanticNodes = flattenSemanticNodes(roots)
34+
const parentById = buildSemanticParentMap(roots)
2335
const nodes = new Map<string, SceneNode>()
2436
const styles = new Map<string, Record<string, string>>()
2537
const svgs = new Map<string, SvgEntry>()
38+
const skipped = new Set<string>()
2639

2740
for (const semantic of semanticNodes) {
41+
if (skipped.has(semantic.id)) continue
42+
2843
const node = figma.getNodeById(semantic.id) as SceneNode | null
2944
if (!node || !node.visible) continue
3045

3146
nodes.set(semantic.id, node)
3247

33-
if (semantic.assetKind === 'vector') {
48+
const isSemanticVectorOnly = semantic.children.length > 0 && isVectorSubtree(semantic)
49+
50+
const isVectorOnlyContainer = isVectorContainer(node) || isSemanticVectorOnly
51+
52+
if (isVectorOnlyContainer && node.width > 0 && node.height > 0) {
3453
const svgEntry = await exportSvgEntry(node, config, assetRegistry)
3554
if (svgEntry) {
3655
svgs.set(semantic.id, svgEntry)
56+
skipDescendants(semantic, skipped)
57+
const processed = await collectNodeStyle(node, semantic, parentById, nodes, styles)
58+
if (processed) {
59+
styles.set(semantic.id, processed)
60+
}
61+
}
62+
continue
63+
}
64+
65+
const shouldExportVector = semantic.assetKind === 'vector' && node.width > 0 && node.height > 0
66+
67+
if (shouldExportVector) {
68+
const svgEntry = await exportSvgEntry(node, config, assetRegistry)
69+
if (svgEntry) {
70+
svgs.set(semantic.id, svgEntry)
71+
const processed = await collectNodeStyle(node, semantic, parentById, nodes, styles)
72+
if (processed) {
73+
styles.set(semantic.id, processed)
74+
}
3775
}
3876
continue
3977
}
@@ -43,6 +81,16 @@ export async function collectSceneData(
4381

4482
let processed = preprocessStyles(css, node)
4583

84+
const parentSemantic = parentById.get(semantic.id)
85+
if (parentSemantic) {
86+
const parentNode =
87+
nodes.get(parentSemantic.id) ?? (figma.getNodeById(parentSemantic.id) as SceneNode | null)
88+
const parentStyle = parentSemantic ? styles.get(parentSemantic.id) : undefined
89+
if (parentNode) {
90+
processed = applyConstraintsPosition(processed, node, parentNode, parentStyle)
91+
}
92+
}
93+
4694
if (hasImageFills(node)) {
4795
processed = await replaceImageUrlsWithAssets(processed, node, config, assetRegistry)
4896
}
@@ -55,9 +103,172 @@ export async function collectSceneData(
55103
}
56104
}
57105

106+
for (const [id, entry] of Array.from(svgs.entries())) {
107+
const node = nodes.get(id)
108+
if (!node) {
109+
svgs.delete(id)
110+
continue
111+
}
112+
if (node.width <= 0 || node.height <= 0) {
113+
svgs.delete(id)
114+
continue
115+
}
116+
const widthAttr = Number(entry.props?.width ?? 0)
117+
const heightAttr = Number(entry.props?.height ?? 0)
118+
if (widthAttr <= 0 || heightAttr <= 0) {
119+
svgs.delete(id)
120+
}
121+
}
122+
58123
return { nodes, styles, svgs }
59124
}
60125

126+
function isVectorContainer(node: SceneNode): boolean {
127+
if (!('children' in node)) return false
128+
const visibleChildren = node.children.filter((child) => child.visible)
129+
if (!visibleChildren.length) return false
130+
return visibleChildren.every((child) => VECTOR_LIKE_TYPES.has(child.type))
131+
}
132+
133+
function isVectorSubtree(semantic: SemanticNode): boolean {
134+
if (!semantic.children.length) return semantic.assetKind === 'vector'
135+
return semantic.children.every((child) => isVectorSubtree(child))
136+
}
137+
138+
function buildSemanticParentMap(roots: SemanticNode[]): Map<string, SemanticNode | undefined> {
139+
const map = new Map<string, SemanticNode | undefined>()
140+
const walk = (node: SemanticNode, parent?: SemanticNode) => {
141+
map.set(node.id, parent)
142+
node.children.forEach((child) => walk(child, node))
143+
}
144+
roots.forEach((root) => walk(root, undefined))
145+
return map
146+
}
147+
148+
async function collectNodeStyle(
149+
node: SceneNode,
150+
semantic: SemanticNode,
151+
parentById: Map<string, SemanticNode | undefined>,
152+
nodes: Map<string, SceneNode>,
153+
styles: Map<string, Record<string, string>>
154+
): Promise<Record<string, string> | null> {
155+
try {
156+
const css = await node.getCSSAsync()
157+
let processed = preprocessStyles(css, node)
158+
159+
const parentSemantic = parentById.get(semantic.id)
160+
if (parentSemantic) {
161+
const parentNode =
162+
nodes.get(parentSemantic.id) ?? (figma.getNodeById(parentSemantic.id) as SceneNode | null)
163+
const parentStyle = parentSemantic ? styles.get(parentSemantic.id) : undefined
164+
if (parentNode) {
165+
processed = applyConstraintsPosition(processed, node, parentNode, parentStyle)
166+
}
167+
}
168+
169+
return processed
170+
} catch (error) {
171+
console.warn('[tempad-dev] Failed to process node styles:', error)
172+
return null
173+
}
174+
}
175+
176+
function applyConstraintsPosition(
177+
style: Record<string, string>,
178+
node: SceneNode,
179+
parent: SceneNode,
180+
parentStyle?: Record<string, string>
181+
): Record<string, string> {
182+
const constraints = (node as { constraints?: Constraints }).constraints
183+
if (!constraints) return style
184+
185+
const layoutMode = (parent as { layoutMode?: string }).layoutMode
186+
if (layoutMode && layoutMode !== 'NONE') return style
187+
188+
const parentDisplay = parentStyle?.display?.toLowerCase()
189+
if (parentDisplay && (parentDisplay.includes('flex') || parentDisplay.includes('grid'))) {
190+
return style
191+
}
192+
193+
const width = (node as { width?: number }).width ?? 0
194+
const height = (node as { height?: number }).height ?? 0
195+
const parentWidth = (parent as { width?: number }).width ?? 0
196+
const parentHeight = (parent as { height?: number }).height ?? 0
197+
198+
if (!parentWidth || !parentHeight) return style
199+
200+
const transform = (node as { relativeTransform?: Transform }).relativeTransform
201+
if (!transform || transform.length < 2 || transform[0].length < 3 || transform[1].length < 3) {
202+
return style
203+
}
204+
205+
const left = transform[0][2]
206+
const top = transform[1][2]
207+
const right = parentWidth - width - left
208+
const bottom = parentHeight - height - top
209+
210+
const result: Record<string, string> = { ...style, position: 'absolute' }
211+
212+
const h = constraints.horizontal
213+
switch (h) {
214+
case 'MIN':
215+
result.left = `${toDecimalPlace(left)}px`
216+
break
217+
case 'MAX':
218+
result.right = `${toDecimalPlace(right)}px`
219+
break
220+
case 'CENTER': {
221+
const offset = width / 2 + (parentWidth / 2 - width / 2 - left)
222+
result.left = `calc(50% - ${toDecimalPlace(offset)}px)`
223+
break
224+
}
225+
case 'STRETCH':
226+
result.left = `${toDecimalPlace(left)}px`
227+
result.right = `${toDecimalPlace(right)}px`
228+
break
229+
case 'SCALE':
230+
result.left = `${toDecimalPlace((left / parentWidth) * 100)}%`
231+
result.right = `${toDecimalPlace((right / parentWidth) * 100)}%`
232+
break
233+
default:
234+
break
235+
}
236+
237+
const v = constraints.vertical
238+
switch (v) {
239+
case 'MIN':
240+
result.top = `${toDecimalPlace(top)}px`
241+
break
242+
case 'MAX':
243+
result.bottom = `${toDecimalPlace(bottom)}px`
244+
break
245+
case 'CENTER': {
246+
const offset = height / 2 + (parentHeight / 2 - height / 2 - top)
247+
result.top = `calc(50% - ${toDecimalPlace(offset)}px)`
248+
break
249+
}
250+
case 'STRETCH':
251+
result.top = `${toDecimalPlace(top)}px`
252+
result.bottom = `${toDecimalPlace(bottom)}px`
253+
break
254+
case 'SCALE':
255+
result.top = `${toDecimalPlace((top / parentHeight) * 100)}%`
256+
result.bottom = `${toDecimalPlace((bottom / parentHeight) * 100)}%`
257+
break
258+
default:
259+
break
260+
}
261+
262+
return result
263+
}
264+
265+
function skipDescendants(semantic: SemanticNode, bucket: Set<string>): void {
266+
semantic.children.forEach((child) => {
267+
bucket.add(child.id)
268+
skipDescendants(child, bucket)
269+
})
270+
}
271+
61272
function flattenSemanticNodes(nodes: SemanticNode[]): SemanticNode[] {
62273
const res: SemanticNode[] = []
63274
const traverse = (n: SemanticNode) => {

packages/extension/mcp/tools/code/index.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { activePlugin } from '@/ui/state'
1212
import { stringifyComponent } from '@/utils/component'
1313
import {
1414
extractVarNames,
15-
normalizeCssVarName,
15+
normalizeCustomPropertyName,
1616
replaceVarFunctions,
1717
stripFallback,
1818
toVarExpr
@@ -21,11 +21,11 @@ import {
2121
import type { RenderContext, CodeLanguage } from './render'
2222

2323
import { currentCodegenConfig } from '../config'
24-
import { collectCandidateVariableIds, resolveTokenDefsByNames } from '../token'
24+
import { resolveTokenDefsByNames } from '../token'
25+
import { applyPluginTransforms, buildVariableMappings, normalizeStyleVars } from '../token/mapping'
2526
import { collectSceneData } from './collect'
2627
import { buildGetCodeMessage } from './messages'
2728
import { renderSemanticNode } from './render'
28-
import { transform } from './variables'
2929

3030
// Tags that should render children without extra whitespace/newlines.
3131
const COMPACT_TAGS = new Set([
@@ -124,13 +124,14 @@ export async function handleGetCode(
124124
const config = currentCodegenConfig()
125125
const pluginCode = activePlugin.value?.code
126126

127+
const mappings = buildVariableMappings(nodes)
128+
127129
const assetRegistry = new Map<string, AssetDescriptor>()
128130
const { nodes: nodeMap, styles, svgs } = await collectSceneData(tree.roots, config, assetRegistry)
129131

130-
const styleVarNames = await transform(styles, {
131-
pluginCode,
132-
config
133-
})
132+
// Normalize codeSyntax-based outputs (e.g. "--ui-bg" / "$kui-color") back to canonical var(--name)
133+
// BEFORE Tailwind conversion, so css->Tailwind operates on a single stable representation.
134+
const usedCandidateIds = normalizeStyleVars(styles, mappings)
134135

135136
const ctx: RenderContext = {
136137
styles,
@@ -160,12 +161,16 @@ export async function handleGetCode(
160161
const MAX_CODE_CHARS = Math.floor(MCP_MAX_PAYLOAD_BYTES * 0.6)
161162
const { markup, message } = buildGetCodeMessage(rawMarkup, MAX_CODE_CHARS, tree.stats)
162163

164+
const finalMarkup = await applyPluginTransforms(markup, pluginCode, config)
165+
163166
// Only include tokens actually referenced in the final output.
164-
const usedTokenNames = new Set<string>(styleVarNames)
165-
extractVarNames(markup).forEach((n) => usedTokenNames.add(n))
167+
const usedTokenNames = new Set<string>()
168+
extractVarNames(finalMarkup).forEach((n) => usedTokenNames.add(n))
169+
// Also capture bare custom property tokens if a plugin emits "--foo" directly.
170+
finalMarkup.match(/--[A-Za-z0-9-_]+/g)?.forEach((m) => usedTokenNames.add(m))
166171

167172
const usedTokens = await resolveTokenDefsByNames(usedTokenNames, config, pluginCode, {
168-
candidateIds: () => collectCandidateVariableIds(nodes).variableIds
173+
candidateIds: usedCandidateIds.size ? usedCandidateIds : mappings.variableIds
169174
})
170175

171176
const codegen = {
@@ -188,7 +193,7 @@ export async function handleGetCode(
188193
out = replaceVarFunctions(out, ({ name, full }) => {
189194
const trimmed = name.trim()
190195
if (!trimmed.startsWith('--')) return full
191-
const canonical = `--${normalizeCssVarName(trimmed.slice(2))}`
196+
const canonical = normalizeCustomPropertyName(trimmed)
192197
const val = tokenMap.get(canonical)
193198
return typeof val === 'string' ? val : toVarExpr(canonical)
194199
})
@@ -201,7 +206,7 @@ export async function handleGetCode(
201206
return out
202207
}
203208

204-
const resolvedMarkup = replaceToken(markup)
209+
const resolvedMarkup = replaceToken(finalMarkup)
205210
// If tokens are resolved, we don't need to return the token definitions
206211
return {
207212
lang: resolvedLang,
@@ -214,7 +219,7 @@ export async function handleGetCode(
214219

215220
return {
216221
lang: resolvedLang,
217-
code: markup,
222+
code: finalMarkup,
218223
assets: Array.from(assetRegistry.values()),
219224
usedTokens,
220225
codegen,

0 commit comments

Comments
 (0)