Skip to content

Commit 326b9a1

Browse files
committed
refactor: refactor token management and variable collection tools
- Introduced `config.ts` to manage current code generation configuration. - Created `candidates.ts` for collecting candidate variable IDs from scene nodes. - Developed `defs.ts` to handle token definitions and resolve tokens by names. - Added `index.ts` to export candidate variable collection and token definition functions. - Implemented `indexer.ts` for indexing tokens and canonicalizing variable names. - Enhanced `worker.ts` to cache plugin code and transform variables efficiently. - Updated `codegen.ts` with a utility function for worker options. - Refactored `css.ts` to improve variable function handling and normalization. - Modified server tools to accurately count token references in responses. - Adjusted shared tools to clarify token definition schema documentation.
1 parent 0fc5c71 commit 326b9a1

File tree

13 files changed

+755
-440
lines changed

13 files changed

+755
-440
lines changed

packages/extension/mcp/runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { selection } from '@/ui/state'
1414
import { handleGetCode as runGetCode } from './tools/code'
1515
import { handleGetScreenshot as runGetScreenshot } from './tools/screenshot'
1616
import { handleGetStructure as runGetStructure } from './tools/structure'
17-
import { handleGetTokenDefs as runGetTokenDefs } from './tools/token-defs'
17+
import { handleGetTokenDefs as runGetTokenDefs } from './tools/token'
1818

1919
function isSceneNode(node: BaseNode | null): node is SceneNode {
2020
return !!node && 'visible' in node && 'type' in node

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

Lines changed: 80 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,27 @@
1-
import type { AssetDescriptor, GetCodeResult, GetTokenDefsResult } from '@tempad-dev/mcp-shared'
1+
import type {
2+
AssetDescriptor,
3+
GetCodeResult,
4+
GetTokenDefsResult,
5+
TokenEntry
6+
} from '@tempad-dev/mcp-shared'
27

38
import { MCP_MAX_PAYLOAD_BYTES } from '@tempad-dev/mcp-shared'
49

5-
import type { CodegenConfig } from '@/utils/codegen'
6-
710
import { buildSemanticTree } from '@/mcp/semantic-tree'
8-
import { activePlugin, options } from '@/ui/state'
11+
import { activePlugin } from '@/ui/state'
912
import { stringifyComponent } from '@/utils/component'
13+
import {
14+
extractVarNames,
15+
normalizeCssVarName,
16+
replaceVarFunctions,
17+
stripFallback,
18+
toVarExpr
19+
} from '@/utils/css'
1020

1121
import type { RenderContext, CodeLanguage } from './render'
1222

13-
import { collectTokenReferences, resolveVariableTokens } from '../token-defs'
23+
import { currentCodegenConfig } from '../config'
24+
import { collectCandidateVariableIds, resolveTokenDefsByNames } from '../token'
1425
import { collectSceneData } from './collect'
1526
import { buildGetCodeMessage } from './messages'
1627
import { renderSemanticNode } from './render'
@@ -52,17 +63,7 @@ const COMPACT_TAGS = new Set([
5263
'summary'
5364
])
5465

55-
function filterTokensByNames(input: GetTokenDefsResult, names: Set<string>): GetTokenDefsResult {
56-
const tokens: Record<string, GetTokenDefsResult['tokens'][string]> = {}
57-
Object.entries(input.tokens).forEach(([name, entry]) => {
58-
if (names.has(name)) {
59-
tokens[name] = entry
60-
}
61-
})
62-
return { tokens }
63-
}
64-
65-
function primaryTokenValue(entry: GetTokenDefsResult['tokens'][string]): string | undefined {
66+
function primaryTokenValue(entry: TokenEntry): string | undefined {
6667
if (typeof entry.value === 'string') return entry.value
6768
if (typeof entry.resolvedValue === 'string') return entry.resolvedValue
6869
if (entry.activeMode && typeof entry.value[entry.activeMode] === 'string') {
@@ -72,6 +73,34 @@ function primaryTokenValue(entry: GetTokenDefsResult['tokens'][string]): string
7273
return typeof first === 'string' ? first : undefined
7374
}
7475

76+
function resolveConcreteValue(
77+
name: string,
78+
allTokens: GetTokenDefsResult,
79+
cache: Map<string, string | undefined>,
80+
depth = 0
81+
): string | undefined {
82+
if (cache.has(name)) return cache.get(name)
83+
if (depth > 20) return undefined
84+
85+
const entry = allTokens[name]
86+
if (!entry) {
87+
cache.set(name, undefined)
88+
return undefined
89+
}
90+
const val = primaryTokenValue(entry)
91+
if (typeof val !== 'string') {
92+
cache.set(name, undefined)
93+
return undefined
94+
}
95+
if (val.startsWith('--') && val !== name) {
96+
const resolved = resolveConcreteValue(val, allTokens, cache, depth + 1) ?? val
97+
cache.set(name, resolved)
98+
return resolved
99+
}
100+
cache.set(name, val)
101+
return val
102+
}
103+
75104
export async function handleGetCode(
76105
nodes: SceneNode[],
77106
preferredLang?: CodeLanguage,
@@ -92,13 +121,13 @@ export async function handleGetCode(
92121
throw new Error('No renderable nodes found for the current selection.')
93122
}
94123

95-
const config = codegenConfig()
124+
const config = currentCodegenConfig()
96125
const pluginCode = activePlugin.value?.code
97126

98127
const assetRegistry = new Map<string, AssetDescriptor>()
99128
const { nodes: nodeMap, styles, svgs } = await collectSceneData(tree.roots, config, assetRegistry)
100129

101-
await applyVariableTransforms(styles, {
130+
const styleVarNames = await applyVariableTransforms(styles, {
102131
pluginCode,
103132
config
104133
})
@@ -131,33 +160,48 @@ export async function handleGetCode(
131160
const MAX_CODE_CHARS = Math.floor(MCP_MAX_PAYLOAD_BYTES * 0.6)
132161
const { markup, message } = buildGetCodeMessage(rawMarkup, MAX_CODE_CHARS, tree.stats)
133162

134-
const { variableIds } = collectTokenReferences(nodes)
135-
const allTokens = await resolveVariableTokens(variableIds, config, pluginCode)
136-
137-
const usedTokenNames = new Set<string>()
138-
// Use a simple regex to capture all var(--name) occurrences, including nested ones.
139-
// We don't use CSS_VAR_FUNCTION_RE because it consumes the whole function and might miss nested vars in fallbacks.
140-
const regex = /var\(--([a-zA-Z0-9-_]+)/g
141-
let match
142-
while ((match = regex.exec(markup))) {
143-
usedTokenNames.add(`--${match[1]}`)
144-
}
163+
// Only include tokens actually referenced in the final output.
164+
const usedTokenNames = new Set<string>(styleVarNames)
165+
extractVarNames(markup).forEach((n) => usedTokenNames.add(n))
145166

146-
const usedTokens = filterTokensByNames(allTokens, usedTokenNames)
167+
const usedTokens = await resolveTokenDefsByNames(usedTokenNames, config, pluginCode, {
168+
candidateIds: () => collectCandidateVariableIds(nodes).variableIds
169+
})
147170

148171
const codegen = {
149172
preset: activePlugin.value?.name ?? 'none',
150173
config
151174
}
152175

153176
if (resolveTokens) {
154-
const tokenMap = new Map(
155-
Object.entries(usedTokens.tokens).map(([name, entry]) => [name, primaryTokenValue(entry)])
156-
)
157-
const resolvedMarkup = markup.replace(/var\((--[a-zA-Z0-9-_]+)\)/g, (match, name) => {
158-
const val = tokenMap.get(name)
159-
return typeof val === 'string' ? val : match
177+
const tokenCache = new Map<string, string | undefined>()
178+
const tokenMap = new Map<string, string | undefined>()
179+
Object.keys(usedTokens).forEach((name) => {
180+
tokenMap.set(name, resolveConcreteValue(name, usedTokens, tokenCache))
160181
})
182+
183+
const replaceToken = (input: string): string => {
184+
// Always strip fallbacks (even if token isn't resolved).
185+
let out = stripFallback(input)
186+
187+
// Replace CSS var() functions first (supports whitespace/nesting).
188+
out = replaceVarFunctions(out, ({ name, full }) => {
189+
const trimmed = name.trim()
190+
if (!trimmed.startsWith('--')) return full
191+
const canonical = `--${normalizeCssVarName(trimmed.slice(2))}`
192+
const val = tokenMap.get(canonical)
193+
return typeof val === 'string' ? val : toVarExpr(canonical)
194+
})
195+
196+
// replace bare --token (e.g., Tailwind arbitrary value: border-[--foo])
197+
out = out.replace(/--[A-Za-z0-9-_]+/g, (m) => {
198+
const val = tokenMap.get(m)
199+
return typeof val === 'string' ? val : m
200+
})
201+
return out
202+
}
203+
204+
const resolvedMarkup = replaceToken(markup)
161205
// If tokens are resolved, we don't need to return the token definitions
162206
return {
163207
lang: resolvedLang,
@@ -191,8 +235,3 @@ function normalizeRootString(content: string, fallbackTag: string | undefined, l
191235
}
192236
)
193237
}
194-
195-
function codegenConfig(): CodegenConfig {
196-
const { cssUnit, rootFontSize, scale } = options.value
197-
return { cssUnit, rootFontSize, scale }
198-
}

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

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import type { CodegenConfig } from '@/utils/codegen'
22

33
import { runTransformVariableBatch } from '@/mcp/transform-variables/requester'
4+
import { workerUnitOptions } from '@/utils/codegen'
45
import {
5-
CSS_VAR_FUNCTION_RE,
6-
canonicalizeVariable,
6+
canonicalizeVarName,
77
expandShorthands,
8+
extractVarNames,
89
formatHexAlpha,
910
normalizeCssVarName,
10-
normalizeStyleVariables,
11+
preprocessCssValue,
12+
replaceVarFunctions,
13+
stripFallback,
1114
normalizeStyleValues,
12-
parseBackgroundShorthand
15+
parseBackgroundShorthand,
16+
toVarExpr
1317
} from '@/utils/css'
1418
import { cssToClassNames } from '@/utils/tailwind'
1519

@@ -357,36 +361,38 @@ type ApplyVariableOptions = {
357361
export async function applyVariableTransforms(
358362
styles: Map<string, Record<string, string>>,
359363
{ pluginCode, config }: ApplyVariableOptions
360-
): Promise<void> {
364+
): Promise<Set<string>> {
361365
const { references, buckets } = collectVariableReferences(styles)
362-
if (!references.length) return
366+
if (!references.length) return new Set()
363367

364368
const transformResults = await runTransformVariableBatch(
365369
references.map(({ code, name, value }) => ({ code, name, value })),
366-
{
367-
useRem: config.cssUnit === 'rem',
368-
rootFontSize: config.rootFontSize ?? 16,
369-
scale: config.scale ?? 1
370-
},
370+
workerUnitOptions(config),
371371
pluginCode
372372
)
373373

374374
const replacements = transformResults.map((result) => {
375-
return canonicalizeVariable(result) || result
375+
const noFallback = stripFallback(result)
376+
const canonicalName = canonicalizeVarName(noFallback)
377+
return canonicalName ? toVarExpr(canonicalName) : noFallback
376378
})
377-
378-
const safeRegex = new RegExp(CSS_VAR_FUNCTION_RE.source, CSS_VAR_FUNCTION_RE.flags)
379+
const usedNames = new Set<string>()
380+
// Keep original reference names to handle transforms that inline literal values.
381+
references.forEach(({ name }) => usedNames.add(`--${name}`))
382+
replacements.forEach((repl) => extractVarNames(repl).forEach((n) => usedNames.add(n)))
379383

380384
for (const bucket of buckets.values()) {
381385
const style = styles.get(bucket.nodeId)
382386
if (!style) continue
383387

384388
let occurrence = 0
385-
style[bucket.property] = bucket.value.replace(safeRegex, (match: string) => {
389+
style[bucket.property] = replaceVarFunctions(bucket.value, ({ full }) => {
386390
const refIndex = bucket.matchIndices[occurrence++]
387-
return replacements[refIndex] ?? match
391+
return refIndex != null ? (replacements[refIndex] ?? full) : full
388392
})
389393
}
394+
395+
return usedNames
390396
}
391397

392398
export function stripInertShadows(style: Record<string, string>, node: SceneNode): void {
@@ -418,38 +424,41 @@ function isFillRenderable(fill: Paint | undefined): boolean {
418424
function collectVariableReferences(styles: Map<string, Record<string, string>>) {
419425
const references: VariableReferenceInternal[] = []
420426
const buckets = new Map<string, PropertyBucket>()
421-
const regex = new RegExp(CSS_VAR_FUNCTION_RE.source, CSS_VAR_FUNCTION_RE.flags)
422427

423428
for (const [nodeId, style] of styles.entries()) {
424-
normalizeStyleVariables(style)
425-
426429
for (const [property, value] of Object.entries(style)) {
427-
let match: RegExpExecArray | null
428430
let hasMatch = false
429431
const indices: number[] = []
430432

431-
regex.lastIndex = 0
432-
while ((match = regex.exec(value))) {
433+
const normalized = preprocessCssValue(value)
434+
if (normalized !== value) {
435+
style[property] = normalized
436+
}
437+
438+
replaceVarFunctions(normalized, ({ full, name, fallback }) => {
439+
const trimmed = name.trim()
440+
if (!trimmed.startsWith('--')) return full
441+
433442
hasMatch = true
434-
const [, name, fallback] = match
435443
const refIndex =
436444
references.push({
437445
nodeId,
438446
property,
439-
code: match[0],
440-
name: normalizeCssVarName(name),
447+
code: full,
448+
name: normalizeCssVarName(trimmed.slice(2)),
441449
value: fallback?.trim()
442450
}) - 1
443451
indices.push(refIndex)
444-
}
452+
return full
453+
})
445454

446455
if (hasMatch) {
447456
const key = `${nodeId}:${property}`
448457
const bucket = buckets.get(key)
449458
if (bucket) {
450459
bucket.matchIndices.push(...indices)
451460
} else {
452-
buckets.set(key, { nodeId, property, value, matchIndices: indices })
461+
buckets.set(key, { nodeId, property, value: normalized, matchIndices: indices })
453462
}
454463
}
455464
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type { CodegenConfig } from '@/utils/codegen'
2+
3+
import { options } from '@/ui/state'
4+
5+
export function currentCodegenConfig(): CodegenConfig {
6+
const { cssUnit, rootFontSize, scale } = options.value
7+
return { cssUnit, rootFontSize, scale }
8+
}

0 commit comments

Comments
 (0)