@mp3wizard/figma-console-mcp 1.25.1 → 1.27.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -33
- package/dist/cloudflare/core/config.js +0 -8
- package/dist/cloudflare/core/console-monitor.js +3 -3
- package/dist/cloudflare/core/diagnose-tool.js +96 -0
- package/dist/cloudflare/core/figma-tools.js +69 -229
- package/dist/cloudflare/core/identity.js +96 -0
- package/dist/cloudflare/core/tokens/alias-resolver.js +98 -0
- package/dist/cloudflare/core/tokens/config.js +284 -0
- package/dist/cloudflare/core/tokens/figma-converter.js +195 -0
- package/dist/cloudflare/core/tokens/formatters/css-vars.js +329 -0
- package/dist/cloudflare/core/tokens/formatters/dtcg.js +300 -0
- package/dist/cloudflare/core/tokens/formatters/index.js +45 -0
- package/dist/cloudflare/core/tokens/formatters/json.js +7 -0
- package/dist/cloudflare/core/tokens/formatters/less.js +4 -0
- package/dist/cloudflare/core/tokens/formatters/scss.js +4 -0
- package/dist/cloudflare/core/tokens/formatters/stubs.js +11 -0
- package/dist/cloudflare/core/tokens/formatters/style-dictionary-v3.js +4 -0
- package/dist/cloudflare/core/tokens/formatters/tailwind-v3.js +4 -0
- package/dist/cloudflare/core/tokens/formatters/tailwind-v4.js +4 -0
- package/dist/cloudflare/core/tokens/formatters/tokens-studio.js +4 -0
- package/dist/cloudflare/core/tokens/formatters/ts-module.js +4 -0
- package/dist/cloudflare/core/tokens/index.js +15 -0
- package/dist/cloudflare/core/tokens/parsers/css-vars.js +4 -0
- package/dist/cloudflare/core/tokens/parsers/dtcg.js +253 -0
- package/dist/cloudflare/core/tokens/parsers/index.js +138 -0
- package/dist/cloudflare/core/tokens/parsers/json.js +7 -0
- package/dist/cloudflare/core/tokens/parsers/scss.js +4 -0
- package/dist/cloudflare/core/tokens/parsers/stubs.js +13 -0
- package/dist/cloudflare/core/tokens/parsers/style-dictionary-v3.js +4 -0
- package/dist/cloudflare/core/tokens/parsers/tailwind-v3.js +4 -0
- package/dist/cloudflare/core/tokens/parsers/tailwind-v4.js +4 -0
- package/dist/cloudflare/core/tokens/parsers/tokens-studio.js +4 -0
- package/dist/cloudflare/core/tokens/schemas.js +148 -0
- package/dist/cloudflare/core/tokens/transforms/color.js +12 -0
- package/dist/cloudflare/core/tokens/transforms/index.js +29 -0
- package/dist/cloudflare/core/tokens/transforms/size.js +7 -0
- package/dist/cloudflare/core/tokens/types.js +18 -0
- package/dist/cloudflare/core/tokens-tools.js +849 -0
- package/dist/cloudflare/core/websocket-server.js +5 -55
- package/dist/cloudflare/index.js +37 -26
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +0 -8
- package/dist/core/config.js.map +1 -1
- package/dist/core/console-monitor.d.ts +2 -2
- package/dist/core/console-monitor.d.ts.map +1 -1
- package/dist/core/console-monitor.js +3 -3
- package/dist/core/console-monitor.js.map +1 -1
- package/dist/core/diagnose-tool.d.ts +33 -0
- package/dist/core/diagnose-tool.d.ts.map +1 -0
- package/dist/core/diagnose-tool.js +97 -0
- package/dist/core/diagnose-tool.js.map +1 -0
- package/dist/core/figma-connector.d.ts +1 -1
- package/dist/core/figma-connector.d.ts.map +1 -1
- package/dist/core/figma-tools.d.ts +1 -2
- package/dist/core/figma-tools.d.ts.map +1 -1
- package/dist/core/figma-tools.js +69 -229
- package/dist/core/figma-tools.js.map +1 -1
- package/dist/core/identity.d.ts +41 -0
- package/dist/core/identity.d.ts.map +1 -0
- package/dist/core/identity.js +97 -0
- package/dist/core/identity.js.map +1 -0
- package/dist/core/tokens/alias-resolver.d.ts +40 -0
- package/dist/core/tokens/alias-resolver.d.ts.map +1 -0
- package/dist/core/tokens/alias-resolver.js +99 -0
- package/dist/core/tokens/alias-resolver.js.map +1 -0
- package/dist/core/tokens/config.d.ts +352 -0
- package/dist/core/tokens/config.d.ts.map +1 -0
- package/dist/core/tokens/config.js +285 -0
- package/dist/core/tokens/config.js.map +1 -0
- package/dist/core/tokens/figma-converter.d.ts +81 -0
- package/dist/core/tokens/figma-converter.d.ts.map +1 -0
- package/dist/core/tokens/figma-converter.js +196 -0
- package/dist/core/tokens/figma-converter.js.map +1 -0
- package/dist/core/tokens/formatters/css-vars.d.ts +24 -0
- package/dist/core/tokens/formatters/css-vars.d.ts.map +1 -0
- package/dist/core/tokens/formatters/css-vars.js +330 -0
- package/dist/core/tokens/formatters/css-vars.js.map +1 -0
- package/dist/core/tokens/formatters/dtcg.d.ts +28 -0
- package/dist/core/tokens/formatters/dtcg.d.ts.map +1 -0
- package/dist/core/tokens/formatters/dtcg.js +301 -0
- package/dist/core/tokens/formatters/dtcg.js.map +1 -0
- package/dist/core/tokens/formatters/index.d.ts +30 -0
- package/dist/core/tokens/formatters/index.d.ts.map +1 -0
- package/dist/core/tokens/formatters/index.js +46 -0
- package/dist/core/tokens/formatters/index.js.map +1 -0
- package/dist/core/tokens/formatters/json.d.ts +5 -0
- package/dist/core/tokens/formatters/json.d.ts.map +1 -0
- package/dist/core/tokens/formatters/json.js +8 -0
- package/dist/core/tokens/formatters/json.js.map +1 -0
- package/dist/core/tokens/formatters/less.d.ts +4 -0
- package/dist/core/tokens/formatters/less.d.ts.map +1 -0
- package/dist/core/tokens/formatters/less.js +5 -0
- package/dist/core/tokens/formatters/less.js.map +1 -0
- package/dist/core/tokens/formatters/scss.d.ts +4 -0
- package/dist/core/tokens/formatters/scss.d.ts.map +1 -0
- package/dist/core/tokens/formatters/scss.js +5 -0
- package/dist/core/tokens/formatters/scss.js.map +1 -0
- package/dist/core/tokens/formatters/stubs.d.ts +9 -0
- package/dist/core/tokens/formatters/stubs.d.ts.map +1 -0
- package/dist/core/tokens/formatters/stubs.js +12 -0
- package/dist/core/tokens/formatters/stubs.js.map +1 -0
- package/dist/core/tokens/formatters/style-dictionary-v3.d.ts +4 -0
- package/dist/core/tokens/formatters/style-dictionary-v3.d.ts.map +1 -0
- package/dist/core/tokens/formatters/style-dictionary-v3.js +5 -0
- package/dist/core/tokens/formatters/style-dictionary-v3.js.map +1 -0
- package/dist/core/tokens/formatters/tailwind-v3.d.ts +4 -0
- package/dist/core/tokens/formatters/tailwind-v3.d.ts.map +1 -0
- package/dist/core/tokens/formatters/tailwind-v3.js +5 -0
- package/dist/core/tokens/formatters/tailwind-v3.js.map +1 -0
- package/dist/core/tokens/formatters/tailwind-v4.d.ts +4 -0
- package/dist/core/tokens/formatters/tailwind-v4.d.ts.map +1 -0
- package/dist/core/tokens/formatters/tailwind-v4.js +5 -0
- package/dist/core/tokens/formatters/tailwind-v4.js.map +1 -0
- package/dist/core/tokens/formatters/tokens-studio.d.ts +4 -0
- package/dist/core/tokens/formatters/tokens-studio.d.ts.map +1 -0
- package/dist/core/tokens/formatters/tokens-studio.js +5 -0
- package/dist/core/tokens/formatters/tokens-studio.js.map +1 -0
- package/dist/core/tokens/formatters/ts-module.d.ts +4 -0
- package/dist/core/tokens/formatters/ts-module.d.ts.map +1 -0
- package/dist/core/tokens/formatters/ts-module.js +5 -0
- package/dist/core/tokens/formatters/ts-module.js.map +1 -0
- package/dist/core/tokens/index.d.ts +17 -0
- package/dist/core/tokens/index.d.ts.map +1 -0
- package/dist/core/tokens/index.js +16 -0
- package/dist/core/tokens/index.js.map +1 -0
- package/dist/core/tokens/parsers/css-vars.d.ts +3 -0
- package/dist/core/tokens/parsers/css-vars.d.ts.map +1 -0
- package/dist/core/tokens/parsers/css-vars.js +5 -0
- package/dist/core/tokens/parsers/css-vars.js.map +1 -0
- package/dist/core/tokens/parsers/dtcg.d.ts +21 -0
- package/dist/core/tokens/parsers/dtcg.d.ts.map +1 -0
- package/dist/core/tokens/parsers/dtcg.js +254 -0
- package/dist/core/tokens/parsers/dtcg.js.map +1 -0
- package/dist/core/tokens/parsers/index.d.ts +37 -0
- package/dist/core/tokens/parsers/index.d.ts.map +1 -0
- package/dist/core/tokens/parsers/index.js +139 -0
- package/dist/core/tokens/parsers/index.js.map +1 -0
- package/dist/core/tokens/parsers/json.d.ts +4 -0
- package/dist/core/tokens/parsers/json.d.ts.map +1 -0
- package/dist/core/tokens/parsers/json.js +8 -0
- package/dist/core/tokens/parsers/json.js.map +1 -0
- package/dist/core/tokens/parsers/scss.d.ts +3 -0
- package/dist/core/tokens/parsers/scss.d.ts.map +1 -0
- package/dist/core/tokens/parsers/scss.js +5 -0
- package/dist/core/tokens/parsers/scss.js.map +1 -0
- package/dist/core/tokens/parsers/stubs.d.ts +11 -0
- package/dist/core/tokens/parsers/stubs.d.ts.map +1 -0
- package/dist/core/tokens/parsers/stubs.js +14 -0
- package/dist/core/tokens/parsers/stubs.js.map +1 -0
- package/dist/core/tokens/parsers/style-dictionary-v3.d.ts +3 -0
- package/dist/core/tokens/parsers/style-dictionary-v3.d.ts.map +1 -0
- package/dist/core/tokens/parsers/style-dictionary-v3.js +5 -0
- package/dist/core/tokens/parsers/style-dictionary-v3.js.map +1 -0
- package/dist/core/tokens/parsers/tailwind-v3.d.ts +3 -0
- package/dist/core/tokens/parsers/tailwind-v3.d.ts.map +1 -0
- package/dist/core/tokens/parsers/tailwind-v3.js +5 -0
- package/dist/core/tokens/parsers/tailwind-v3.js.map +1 -0
- package/dist/core/tokens/parsers/tailwind-v4.d.ts +3 -0
- package/dist/core/tokens/parsers/tailwind-v4.d.ts.map +1 -0
- package/dist/core/tokens/parsers/tailwind-v4.js +5 -0
- package/dist/core/tokens/parsers/tailwind-v4.js.map +1 -0
- package/dist/core/tokens/parsers/tokens-studio.d.ts +3 -0
- package/dist/core/tokens/parsers/tokens-studio.d.ts.map +1 -0
- package/dist/core/tokens/parsers/tokens-studio.js +5 -0
- package/dist/core/tokens/parsers/tokens-studio.js.map +1 -0
- package/dist/core/tokens/schemas.d.ts +152 -0
- package/dist/core/tokens/schemas.d.ts.map +1 -0
- package/dist/core/tokens/schemas.js +149 -0
- package/dist/core/tokens/schemas.js.map +1 -0
- package/dist/core/tokens/transforms/color.d.ts +9 -0
- package/dist/core/tokens/transforms/color.d.ts.map +1 -0
- package/dist/core/tokens/transforms/color.js +13 -0
- package/dist/core/tokens/transforms/color.js.map +1 -0
- package/dist/core/tokens/transforms/index.d.ts +36 -0
- package/dist/core/tokens/transforms/index.d.ts.map +1 -0
- package/dist/core/tokens/transforms/index.js +30 -0
- package/dist/core/tokens/transforms/index.js.map +1 -0
- package/dist/core/tokens/transforms/size.d.ts +7 -0
- package/dist/core/tokens/transforms/size.d.ts.map +1 -0
- package/dist/core/tokens/transforms/size.js +8 -0
- package/dist/core/tokens/transforms/size.js.map +1 -0
- package/dist/core/tokens/types.d.ts +228 -0
- package/dist/core/tokens/types.d.ts.map +1 -0
- package/dist/core/tokens/types.js +19 -0
- package/dist/core/tokens/types.js.map +1 -0
- package/dist/core/tokens-tools.d.ts +42 -0
- package/dist/core/tokens-tools.d.ts.map +1 -0
- package/dist/core/tokens-tools.js +850 -0
- package/dist/core/tokens-tools.js.map +1 -0
- package/dist/core/types/index.d.ts +0 -8
- package/dist/core/types/index.d.ts.map +1 -1
- package/dist/core/websocket-connector.d.ts +1 -1
- package/dist/core/websocket-connector.d.ts.map +1 -1
- package/dist/core/websocket-server.d.ts +4 -3
- package/dist/core/websocket-server.d.ts.map +1 -1
- package/dist/core/websocket-server.js +5 -55
- package/dist/core/websocket-server.js.map +1 -1
- package/dist/local.d.ts +0 -12
- package/dist/local.d.ts.map +1 -1
- package/dist/local.js +959 -3406
- package/dist/local.js.map +1 -1
- package/figma-desktop-bridge/code.js +11 -63
- package/figma-desktop-bridge/ui.html +72 -11
- package/package.json +10 -9
- package/figma-desktop-bridge/ui-full.html +0 -1353
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool registrar for figma_export_tokens and figma_import_tokens.
|
|
3
|
+
*
|
|
4
|
+
* Current scope (v1.27.0):
|
|
5
|
+
* - figma_export_tokens: working for DTCG JSON (canonical) and CSS
|
|
6
|
+
* custom properties output. Other formats (Tailwind v4, SCSS, TS
|
|
7
|
+
* module, Tokens Studio, Style Dictionary v3) are scaffolded and
|
|
8
|
+
* return TokenFormatNotImplementedError with a helpful message
|
|
9
|
+
* directing users to DTCG.
|
|
10
|
+
* - figma_import_tokens: working for DTCG JSON input with full
|
|
11
|
+
* diff-aware merge. Apply phase pushes value updates (toUpdate) to
|
|
12
|
+
* Figma via the plugin bridge — verified end-to-end against real
|
|
13
|
+
* multi-mode design systems. toCreate / toDelete / alias-target
|
|
14
|
+
* updates surface in the diff plan but are not yet wired through
|
|
15
|
+
* the apply phase (use figma_setup_design_tokens /
|
|
16
|
+
* figma_batch_create_variables / figma_delete_variable manually for
|
|
17
|
+
* those for now).
|
|
18
|
+
*
|
|
19
|
+
* Both tools auto-discover `tokens.config.json` at the project root and use
|
|
20
|
+
* its source/generated/modes/conflictResolution settings as defaults. They
|
|
21
|
+
* stay zero-arg in normal use.
|
|
22
|
+
*/
|
|
23
|
+
import { writeFileSync, mkdirSync, existsSync, readFileSync, readdirSync, } from "node:fs";
|
|
24
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
25
|
+
import { createChildLogger } from "./logger.js";
|
|
26
|
+
import { convertFigmaVariablesToDocument, ExportTokensInputSchema, ImportTokensInputSchema, format as formatTokenDocument, loadTokensConfig, parse as parseTokenPayload, resolveOutputTargets, } from "./tokens/index.js";
|
|
27
|
+
const logger = createChildLogger({ component: "tokens-tools" });
|
|
28
|
+
/**
|
|
29
|
+
* MCP version stamp embedded in DTCG `$extensions["figma-console-mcp"].mcpVersion`
|
|
30
|
+
* on every exported token document. Kept in sync with package.json by
|
|
31
|
+
* scripts/release.sh — see step 3 of the release flow.
|
|
32
|
+
*/
|
|
33
|
+
const MCP_VERSION = "1.27.1";
|
|
34
|
+
const EXPORT_TOOL_DESCRIPTION = `Export Figma variables to design token files in your codebase. Bidirectional with figma_import_tokens — together they replace Style Dictionary and Tokens Studio's export pipeline for the popular styling methods.
|
|
35
|
+
|
|
36
|
+
CANONICAL OUTPUT IS DTCG JSON (https://tr.designtokens.org/format/). CSS custom properties is also fully implemented and produces \`:root\` + \`.dark\` + \`[data-theme=...]\` mode selectors with proper string quoting and composite-token expansion. Remaining formats (Tokens Studio, Tailwind v4 @theme, Tailwind v3 config, SCSS, Less, TypeScript module, JSON flat/nested, Style Dictionary v3) are scaffolded but throw TokenFormatNotImplementedError — convert via DTCG for now or open an issue with your specific styling stack.
|
|
37
|
+
|
|
38
|
+
ZERO-ARG USAGE: With a tokens.config.json at your project root, just call the tool with no args — it picks up source dir, output formats, modes, prefix, etc. from config. See the response's \`suggestedScaffold\` payload when no config is detected — present it to the user, write the scaffold via your file tools, then call again.
|
|
39
|
+
|
|
40
|
+
MERGE STRATEGY: Default \`strategy: "merge"\` only writes tokens that actually changed in Figma since the last sync. Use \`dry-run\` to preview what would change. Use \`replace\` to wipe and rewrite (rare; for resetting drift).
|
|
41
|
+
|
|
42
|
+
ROUND-TRIP SAFETY: Figma variable IDs are preserved in DTCG \`$extensions["figma-console-mcp"]\` so renames on either side don't create duplicates. The same metadata enables non-destructive incremental sync via figma_import_tokens.`;
|
|
43
|
+
const IMPORT_TOOL_DESCRIPTION = `Push design tokens from your codebase into Figma as variables. Bidirectional with figma_export_tokens.
|
|
44
|
+
|
|
45
|
+
ACCEPTS: DTCG JSON (canonical, fully supported including round-trip metadata preservation). Tokens Studio JSON, CSS custom properties, Tailwind v4 @theme, SCSS, and Style Dictionary v3 are scaffolded but return a NotImplementedError — convert to DTCG first via figma_export_tokens or hand-author DTCG. Use \`format: "auto"\` to sniff the input.
|
|
46
|
+
|
|
47
|
+
APPLY PHASE: Value updates (toUpdate entries) are pushed to Figma via the plugin bridge in a batched single round-trip — verified end-to-end on multi-mode collections. Partial-success semantics: per-variable errors surface in applyResult.errors[] without failing the batch. toCreate (new variables), toDelete (Figma-only tokens), and alias-target updates are reported in the diff plan but not yet wired through the apply phase — use figma_setup_design_tokens / figma_batch_create_variables / figma_delete_variable manually for those operations, or wait for a future minor version.
|
|
48
|
+
|
|
49
|
+
DIFF-AWARE: Default \`strategy: "merge"\` diffs against current Figma state and applies only deltas. The hacked-color scenario — designer edits one hex value in their CSS — produces exactly one Figma API update, not a full collection rewrite. Match priority: Figma variable ID (in \`$extensions["figma-console-mcp"].variableId\`), then exact token path, then value fingerprint.
|
|
50
|
+
|
|
51
|
+
CONFLICT HANDLING: When BOTH Figma and code changed the same token since the last sync, \`onConflict: "ask"\` (default) surfaces the conflict and writes nothing. Use \`figma-wins\` / \`code-wins\` to auto-resolve, or \`skip\` to leave conflicts alone and proceed with the rest.
|
|
52
|
+
|
|
53
|
+
DRY-RUN: Default first call after detecting changes is dry-run for safety. The response includes the full diff plan; user confirms, then call again with \`dryRun: false\` (or \`strategy\` other than dry-run) to apply.`;
|
|
54
|
+
export function registerExportTokensTool(server, getDesktopConnector, opts = {}) {
|
|
55
|
+
server.tool("figma_export_tokens", EXPORT_TOOL_DESCRIPTION, ExportTokensInputSchema.shape, async (args) => {
|
|
56
|
+
try {
|
|
57
|
+
return await handleExport(args, getDesktopConnector, opts);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
logger.error({ err }, "figma_export_tokens failed");
|
|
61
|
+
return {
|
|
62
|
+
content: [
|
|
63
|
+
{
|
|
64
|
+
type: "text",
|
|
65
|
+
text: JSON.stringify({
|
|
66
|
+
error: err instanceof Error ? err.message : String(err),
|
|
67
|
+
hint: "If this is a TokenFormatNotImplementedError for a non-DTCG/non-CSS format, export to 'dtcg' or 'css-vars' instead — those are the fully-implemented formats. The canonical DTCG JSON can be consumed by Style Dictionary v4 or any other DTCG-aware tooling.",
|
|
68
|
+
}),
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
isError: true,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
export function registerImportTokensTool(server, getDesktopConnector, opts = {}) {
|
|
77
|
+
server.tool("figma_import_tokens", IMPORT_TOOL_DESCRIPTION, ImportTokensInputSchema._def.schema.shape, async (args) => {
|
|
78
|
+
try {
|
|
79
|
+
return await handleImport(args, getDesktopConnector, opts);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
logger.error({ err }, "figma_import_tokens failed");
|
|
83
|
+
return {
|
|
84
|
+
content: [
|
|
85
|
+
{
|
|
86
|
+
type: "text",
|
|
87
|
+
text: JSON.stringify({
|
|
88
|
+
error: err instanceof Error ? err.message : String(err),
|
|
89
|
+
hint: "If this is a NotImplementedError for a non-DTCG format, convert the source to DTCG first (e.g. via figma_export_tokens then edit the JSON).",
|
|
90
|
+
}),
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
isError: true,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Convenience: register both tools at once.
|
|
100
|
+
*/
|
|
101
|
+
export function registerTokensTools(server, getDesktopConnector, opts = {}) {
|
|
102
|
+
registerExportTokensTool(server, getDesktopConnector, opts);
|
|
103
|
+
registerImportTokensTool(server, getDesktopConnector, opts);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Standardized error for fs-dependent paths called in Cloud Mode.
|
|
107
|
+
*/
|
|
108
|
+
function cloudModeFsError(operation) {
|
|
109
|
+
return new Error(`[figma-console-mcp] ${operation} is a Local Mode operation — Cloud Mode (Cloudflare Workers) has no local filesystem access. ` +
|
|
110
|
+
"Use one of these alternatives:\n" +
|
|
111
|
+
" • Export: omit `configPath` and `outputPath` — the tool will return token content inline in the response. Have your AI client write the files via its own Edit/Write tools.\n" +
|
|
112
|
+
" • Import: pass token data inline via the `payload` argument (single file) or `files` argument (multi-file). Omit `configPath`.\n" +
|
|
113
|
+
"For full filesystem support (tokens.config.json autodiscovery, automatic writes to source/generated dirs), run the MCP in Local Mode via NPX.");
|
|
114
|
+
}
|
|
115
|
+
// ============================================================================
|
|
116
|
+
// HANDLERS
|
|
117
|
+
// ============================================================================
|
|
118
|
+
async function handleExport(args, getDesktopConnector, opts) {
|
|
119
|
+
// Cloud Mode guard. Any filesystem operation needs to bail with a clear
|
|
120
|
+
// message before the fs call actually throws something cryptic.
|
|
121
|
+
if (opts.isRemoteMode) {
|
|
122
|
+
if (args.configPath) {
|
|
123
|
+
throw cloudModeFsError("`configPath` (tokens.config.json autodiscovery)");
|
|
124
|
+
}
|
|
125
|
+
if (args.outputPath) {
|
|
126
|
+
throw cloudModeFsError("`outputPath` (writing token files to disk)");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// 1. Load config (autodiscover or explicit). Skip entirely in Cloud Mode
|
|
130
|
+
// — tokens.config.json lookup requires a filesystem.
|
|
131
|
+
const loaded = opts.isRemoteMode
|
|
132
|
+
? null
|
|
133
|
+
: loadTokensConfig({ explicitPath: args.configPath });
|
|
134
|
+
// 2. Fetch variables from Figma via the desktop connector. The connector's
|
|
135
|
+
// getVariablesFromPluginUI returns the plugin's cached variable data
|
|
136
|
+
// (instant, all plans, full Plugin API fidelity).
|
|
137
|
+
const connector = await getDesktopConnector();
|
|
138
|
+
// We don't have a specific fileKey here unless the caller passed one in
|
|
139
|
+
// config; pass undefined to let the connector use the currently-connected
|
|
140
|
+
// file context.
|
|
141
|
+
const fileKey = loaded?.config?.figmaFile
|
|
142
|
+
? extractFileKey(loaded.config.figmaFile)
|
|
143
|
+
: undefined;
|
|
144
|
+
const raw = await connector.getVariablesFromPluginUI(fileKey);
|
|
145
|
+
// Unwrap the plugin's response — same logic as the existing figma_get_variables.
|
|
146
|
+
const variableData = raw?.result?.variables ? raw.result : raw;
|
|
147
|
+
if (!variableData?.variables) {
|
|
148
|
+
throw new Error("[figma-console-mcp] No variables found in the connected Figma file. Make sure the Desktop Bridge plugin is running and the file has at least one variable collection.");
|
|
149
|
+
}
|
|
150
|
+
// 3. Normalize to the converter's expected shape.
|
|
151
|
+
const payload = normalizeFigmaPayload(variableData);
|
|
152
|
+
// 4. Convert to canonical TokenDocument.
|
|
153
|
+
const { document, warnings } = convertFigmaVariablesToDocument(payload, {
|
|
154
|
+
figmaFileKey: fileKey,
|
|
155
|
+
collectionIds: args.collectionIds,
|
|
156
|
+
modes: args.modes,
|
|
157
|
+
stripPrefix: args.prefix,
|
|
158
|
+
mcpVersion: MCP_VERSION,
|
|
159
|
+
});
|
|
160
|
+
// 5. Resolve which output formats to emit.
|
|
161
|
+
const targets = resolveOutputTargets(loaded?.config ?? null, args.format);
|
|
162
|
+
// 6. Format the document for each target.
|
|
163
|
+
const allFiles = [];
|
|
164
|
+
const allWarnings = [...warnings];
|
|
165
|
+
for (const target of targets) {
|
|
166
|
+
try {
|
|
167
|
+
const result = formatTokenDocument(document, {
|
|
168
|
+
target: {
|
|
169
|
+
...target,
|
|
170
|
+
splitByMode: args.splitByMode ?? target.splitByMode,
|
|
171
|
+
splitByCollection: args.splitByCollection ?? target.splitByCollection,
|
|
172
|
+
prefix: args.prefix ?? target.prefix,
|
|
173
|
+
resolveAliases: args.resolveAliases ?? target.resolveAliases,
|
|
174
|
+
transforms: {
|
|
175
|
+
colorFormat: args.colorFormat ?? target.transforms?.colorFormat,
|
|
176
|
+
sizeUnit: args.sizeUnit ?? target.transforms?.sizeUnit,
|
|
177
|
+
remBase: args.remBase ?? target.transforms?.remBase,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
projectRoot: loaded?.projectRoot,
|
|
181
|
+
});
|
|
182
|
+
for (const file of result.files) {
|
|
183
|
+
allFiles.push({ format: target.format, ...file });
|
|
184
|
+
}
|
|
185
|
+
allWarnings.push(...result.warnings);
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
// Non-DTCG formatters throw NotImplementedError. Surface it without
|
|
189
|
+
// bailing on the other targets.
|
|
190
|
+
allWarnings.push(`[${target.format}] ${err instanceof Error ? err.message : String(err)}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// 7. If outputPath is set, write to disk. Otherwise return inline.
|
|
194
|
+
// Output routing: canonical-format files (matching config.source.canonical)
|
|
195
|
+
// go to source.dir; everything else goes to generated.dir.
|
|
196
|
+
const dryRun = args.strategy === "dry-run";
|
|
197
|
+
const writtenPaths = [];
|
|
198
|
+
if (!dryRun) {
|
|
199
|
+
for (const file of allFiles) {
|
|
200
|
+
const base = resolveOutputBaseForFormat(args.outputPath, loaded, file.format);
|
|
201
|
+
if (!base)
|
|
202
|
+
continue; // No config or outputPath → caller will get content inline.
|
|
203
|
+
const fullPath = isAbsolute(file.path)
|
|
204
|
+
? file.path
|
|
205
|
+
: join(base, file.path);
|
|
206
|
+
mkdirSync(dirname(fullPath), { recursive: true });
|
|
207
|
+
writeFileSync(fullPath, file.content, "utf-8");
|
|
208
|
+
writtenPaths.push(fullPath);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const outputBase = writtenPaths.length > 0 ? "(multiple)" : null;
|
|
212
|
+
return {
|
|
213
|
+
content: [
|
|
214
|
+
{
|
|
215
|
+
type: "text",
|
|
216
|
+
text: JSON.stringify({
|
|
217
|
+
success: true,
|
|
218
|
+
mode: dryRun ? "dry-run" : outputBase ? "written" : "inline",
|
|
219
|
+
configFound: !!loaded,
|
|
220
|
+
configPath: loaded?.configPath ?? null,
|
|
221
|
+
collections: document.sets.map((s) => ({
|
|
222
|
+
name: s.name,
|
|
223
|
+
modes: s.modes,
|
|
224
|
+
tokenCount: s.tokens.length,
|
|
225
|
+
figmaCollectionId: s.meta?.figmaCollectionId,
|
|
226
|
+
})),
|
|
227
|
+
outputs: dryRun
|
|
228
|
+
? allFiles.map((f) => ({
|
|
229
|
+
format: f.format,
|
|
230
|
+
path: f.path,
|
|
231
|
+
preview: f.content.slice(0, 500) + (f.content.length > 500 ? "…" : ""),
|
|
232
|
+
}))
|
|
233
|
+
: outputBase
|
|
234
|
+
? writtenPaths.map((p) => ({ writtenTo: p }))
|
|
235
|
+
: allFiles,
|
|
236
|
+
warnings: allWarnings,
|
|
237
|
+
...(loaded
|
|
238
|
+
? {}
|
|
239
|
+
: {
|
|
240
|
+
suggestedScaffold: {
|
|
241
|
+
note: "No tokens.config.json detected. Recommended scaffold:",
|
|
242
|
+
configContent: JSON.stringify({
|
|
243
|
+
$schema: "https://figma-console-mcp.southleft.com/schemas/tokens.config.v1.json",
|
|
244
|
+
source: { dir: "src/styles/tokens", canonical: "dtcg" },
|
|
245
|
+
generated: {
|
|
246
|
+
dir: "src/styles/generated",
|
|
247
|
+
formats: [{ format: "css-vars", splitByMode: true }],
|
|
248
|
+
},
|
|
249
|
+
conflictResolution: "ask",
|
|
250
|
+
}, null, 2),
|
|
251
|
+
directories: ["src/styles/tokens", "src/styles/generated"],
|
|
252
|
+
nextSteps: "Write tokens.config.json at the project root, create the directories, then call figma_export_tokens again — zero args needed.",
|
|
253
|
+
},
|
|
254
|
+
}),
|
|
255
|
+
}, null, 2),
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
async function handleImport(args, getDesktopConnector, opts) {
|
|
261
|
+
// Cloud Mode guard: filesystem operations are unavailable. Inline
|
|
262
|
+
// `payload` / `files` arguments still work and are the supported path.
|
|
263
|
+
if (opts.isRemoteMode) {
|
|
264
|
+
if (args.configPath) {
|
|
265
|
+
throw cloudModeFsError("`configPath` (tokens.config.json autodiscovery)");
|
|
266
|
+
}
|
|
267
|
+
if (!args.payload && !args.files) {
|
|
268
|
+
throw cloudModeFsError("Implicit source-dir reads (when neither `payload` nor `files` is provided)");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// 1. Load config + resolve where the source payload(s) live.
|
|
272
|
+
const loaded = opts.isRemoteMode
|
|
273
|
+
? null
|
|
274
|
+
: loadTokensConfig({ explicitPath: args.configPath });
|
|
275
|
+
// 2. Collect input payloads.
|
|
276
|
+
const inputFiles = collectInputFiles(args, loaded);
|
|
277
|
+
// 3. Parse each input file to a TokenDocument.
|
|
278
|
+
const documents = [];
|
|
279
|
+
const parseWarnings = [];
|
|
280
|
+
for (const file of inputFiles) {
|
|
281
|
+
const parseResult = parseTokenPayload(args.format ?? "auto", {
|
|
282
|
+
payload: file.content,
|
|
283
|
+
sourcePath: file.path,
|
|
284
|
+
});
|
|
285
|
+
documents.push(parseResult.document);
|
|
286
|
+
parseWarnings.push(...parseResult.warnings);
|
|
287
|
+
}
|
|
288
|
+
// 4. Merge documents into a single TokenDocument (sets are concatenated;
|
|
289
|
+
// tokens within sets are combined by path).
|
|
290
|
+
const merged = mergeDocuments(documents);
|
|
291
|
+
// 5. Fetch current Figma state for diffing.
|
|
292
|
+
const connector = await getDesktopConnector();
|
|
293
|
+
const fileKey = loaded?.config?.figmaFile
|
|
294
|
+
? extractFileKey(loaded.config.figmaFile)
|
|
295
|
+
: undefined;
|
|
296
|
+
const raw = await connector.getVariablesFromPluginUI(fileKey);
|
|
297
|
+
const variableData = raw?.result?.variables ? raw.result : raw;
|
|
298
|
+
const figmaPayload = normalizeFigmaPayload(variableData ?? { variables: [], variableCollections: [] });
|
|
299
|
+
const { document: figmaDoc } = convertFigmaVariablesToDocument(figmaPayload, {
|
|
300
|
+
figmaFileKey: fileKey,
|
|
301
|
+
mcpVersion: MCP_VERSION,
|
|
302
|
+
});
|
|
303
|
+
// 6. Compute the diff plan.
|
|
304
|
+
const diff = computeDiffPlan(figmaDoc, merged);
|
|
305
|
+
const dryRun = args.dryRun === true || args.strategy === "dry-run";
|
|
306
|
+
// 7. Apply phase: when not dry-run, push toUpdate entries to Figma via
|
|
307
|
+
// the plugin's executeCodeViaUI. Create + delete are stubbed for a
|
|
308
|
+
// future phase (value updates cover the common designer workflow:
|
|
309
|
+
// edit a hex value in JSON, push to Figma).
|
|
310
|
+
let applyResult = null;
|
|
311
|
+
if (!dryRun && diff.toUpdate.length > 0) {
|
|
312
|
+
const collectionModeMap = buildCollectionModeMap(figmaPayload);
|
|
313
|
+
const updates = buildUpdatePayloads(diff.toUpdate, figmaDoc, merged, collectionModeMap, parseWarnings);
|
|
314
|
+
if (updates.length > 0) {
|
|
315
|
+
applyResult = await applyUpdates(connector, updates);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// Slim the diff for the response: full entries blow past LLM context for
|
|
319
|
+
// large design systems. Show counts + a sample of first N entries from
|
|
320
|
+
// each bucket; the caller can re-run with format=detailed if they want
|
|
321
|
+
// everything.
|
|
322
|
+
const SAMPLE_LIMIT = 20;
|
|
323
|
+
const slimDiff = {
|
|
324
|
+
summary: {
|
|
325
|
+
toCreate: diff.toCreate.length,
|
|
326
|
+
toUpdate: diff.toUpdate.length,
|
|
327
|
+
toDelete: diff.toDelete.length,
|
|
328
|
+
unchanged: diff.unchanged,
|
|
329
|
+
},
|
|
330
|
+
samples: {
|
|
331
|
+
toCreate: diff.toCreate.slice(0, SAMPLE_LIMIT).map((e) => ({
|
|
332
|
+
path: e.path,
|
|
333
|
+
type: e.type,
|
|
334
|
+
})),
|
|
335
|
+
toUpdate: diff.toUpdate.slice(0, SAMPLE_LIMIT),
|
|
336
|
+
toDelete: diff.toDelete.slice(0, SAMPLE_LIMIT),
|
|
337
|
+
},
|
|
338
|
+
truncated: {
|
|
339
|
+
toCreate: diff.toCreate.length > SAMPLE_LIMIT,
|
|
340
|
+
toUpdate: diff.toUpdate.length > SAMPLE_LIMIT,
|
|
341
|
+
toDelete: diff.toDelete.length > SAMPLE_LIMIT,
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
return {
|
|
345
|
+
content: [
|
|
346
|
+
{
|
|
347
|
+
type: "text",
|
|
348
|
+
text: JSON.stringify({
|
|
349
|
+
success: true,
|
|
350
|
+
mode: dryRun ? "dry-run" : applyResult ? "applied" : "no-changes",
|
|
351
|
+
applyNote: dryRun
|
|
352
|
+
? "Dry-run only — no Figma mutations performed."
|
|
353
|
+
: applyResult
|
|
354
|
+
? `Applied ${applyResult.applied} value update(s) to Figma. ${applyResult.failed} failed.`
|
|
355
|
+
: diff.toUpdate.length === 0
|
|
356
|
+
? "Nothing to apply — all tokens already in sync."
|
|
357
|
+
: "Updates were detected but skipped (likely all aliases or unresolved values).",
|
|
358
|
+
toCreatePhase2Note: diff.toCreate.length > 0
|
|
359
|
+
? `${diff.toCreate.length} create(s) detected — create-phase mutations ship in a future phase. Use figma_setup_design_tokens / figma_batch_create_variables manually for now.`
|
|
360
|
+
: undefined,
|
|
361
|
+
toDeletePhase2Note: diff.toDelete.length > 0
|
|
362
|
+
? `${diff.toDelete.length} Figma-only token(s) preserved (merge strategy). Use strategy: "replace" to delete them, or figma_delete_variable manually.`
|
|
363
|
+
: undefined,
|
|
364
|
+
inputFileCount: inputFiles.length,
|
|
365
|
+
parsedSetCount: merged.sets.length,
|
|
366
|
+
parsedTokenCount: merged.sets.reduce((n, s) => n + s.tokens.length, 0),
|
|
367
|
+
diff: slimDiff,
|
|
368
|
+
applyResult: applyResult
|
|
369
|
+
? {
|
|
370
|
+
applied: applyResult.applied,
|
|
371
|
+
failed: applyResult.failed,
|
|
372
|
+
errors: applyResult.errors.slice(0, 10),
|
|
373
|
+
}
|
|
374
|
+
: null,
|
|
375
|
+
warnings: parseWarnings,
|
|
376
|
+
}, null, 2),
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
// ============================================================================
|
|
382
|
+
// HELPERS
|
|
383
|
+
// ============================================================================
|
|
384
|
+
/**
|
|
385
|
+
* Extract a Figma file key from a URL or return the string as-is if it's
|
|
386
|
+
* already a key.
|
|
387
|
+
*/
|
|
388
|
+
function extractFileKey(figmaFileOrUrl) {
|
|
389
|
+
const match = figmaFileOrUrl.match(/figma\.com\/(?:file|design)\/([a-zA-Z0-9]+)/);
|
|
390
|
+
return match ? match[1] : figmaFileOrUrl;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Normalize the plugin's variable response into the converter's expected
|
|
394
|
+
* shape. The plugin may return collections keyed by ID or as an array;
|
|
395
|
+
* normalize both into an array.
|
|
396
|
+
*/
|
|
397
|
+
function normalizeFigmaPayload(raw) {
|
|
398
|
+
const collections = Array.isArray(raw.variableCollections)
|
|
399
|
+
? raw.variableCollections
|
|
400
|
+
: Object.entries(raw.variableCollections ?? {}).map(([id, c]) => ({ id, ...c }));
|
|
401
|
+
const variables = Array.isArray(raw.variables)
|
|
402
|
+
? raw.variables
|
|
403
|
+
: Object.entries(raw.variables ?? {}).map(([id, v]) => ({
|
|
404
|
+
id,
|
|
405
|
+
...v,
|
|
406
|
+
}));
|
|
407
|
+
return { collections, variables };
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Resolve where to write output files for a specific format. Canonical formats
|
|
411
|
+
* (matching config.source.canonical) go to source.dir; everything else goes
|
|
412
|
+
* to generated.dir. Caller-supplied outputPath wins over both.
|
|
413
|
+
*/
|
|
414
|
+
function resolveOutputBaseForFormat(outputPath, loaded, format) {
|
|
415
|
+
// Explicit outputPath always wins.
|
|
416
|
+
if (outputPath) {
|
|
417
|
+
return isAbsolute(outputPath)
|
|
418
|
+
? outputPath
|
|
419
|
+
: resolve(loaded?.projectRoot ?? process.cwd(), outputPath);
|
|
420
|
+
}
|
|
421
|
+
if (!loaded)
|
|
422
|
+
return null;
|
|
423
|
+
// Canonical format goes to source.dir.
|
|
424
|
+
if (format === loaded.config.source.canonical) {
|
|
425
|
+
return resolve(loaded.projectRoot, loaded.config.source.dir);
|
|
426
|
+
}
|
|
427
|
+
// Otherwise generated.dir.
|
|
428
|
+
if (loaded.config.generated?.dir) {
|
|
429
|
+
return resolve(loaded.projectRoot, loaded.config.generated.dir);
|
|
430
|
+
}
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Gather input files for import. Priority:
|
|
435
|
+
* 1. Explicit payload string.
|
|
436
|
+
* 2. Explicit files array.
|
|
437
|
+
* 3. Config-derived source dir (read every *.tokens.json file).
|
|
438
|
+
*/
|
|
439
|
+
function collectInputFiles(args, loaded) {
|
|
440
|
+
if (args.payload) {
|
|
441
|
+
return [{ path: "<inline>", content: args.payload }];
|
|
442
|
+
}
|
|
443
|
+
if (args.files?.length) {
|
|
444
|
+
return args.files;
|
|
445
|
+
}
|
|
446
|
+
if (!loaded) {
|
|
447
|
+
throw new Error("[figma-console-mcp] No payload, files, or tokens.config.json supplied. Pass one of: { payload }, { files }, or have tokens.config.json at the project root.");
|
|
448
|
+
}
|
|
449
|
+
// Walk the source dir for *.tokens.json files. Currently a flat scan;
|
|
450
|
+
// the config's source.pattern is honored as a simple glob (just suffix
|
|
451
|
+
// matching for now).
|
|
452
|
+
const sourceDir = resolve(loaded.projectRoot, loaded.config.source.dir);
|
|
453
|
+
if (!existsSync(sourceDir)) {
|
|
454
|
+
throw new Error(`[figma-console-mcp] Source dir does not exist: ${sourceDir}. Make sure tokens.config.json's source.dir points at a directory that exists.`);
|
|
455
|
+
}
|
|
456
|
+
const pattern = loaded.config.source.pattern ?? "*.tokens.json";
|
|
457
|
+
const suffix = pattern.replace(/^\*/, "");
|
|
458
|
+
const entries = readdirSync(sourceDir);
|
|
459
|
+
return entries
|
|
460
|
+
.filter((e) => e.endsWith(suffix))
|
|
461
|
+
.map((name) => {
|
|
462
|
+
const full = join(sourceDir, name);
|
|
463
|
+
return { path: full, content: readFileSync(full, "utf-8") };
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Merge multiple TokenDocuments. Sets with the same name combine their
|
|
468
|
+
* tokens; tokens with the same path within a set have their mode-values
|
|
469
|
+
* merged (so splitByMode files reassemble cleanly into one multi-mode
|
|
470
|
+
* representation). Modes are unioned. Document-level metadata uses the
|
|
471
|
+
* first document's values.
|
|
472
|
+
*/
|
|
473
|
+
function mergeDocuments(docs) {
|
|
474
|
+
if (docs.length === 0) {
|
|
475
|
+
return { sets: [], meta: {} };
|
|
476
|
+
}
|
|
477
|
+
if (docs.length === 1)
|
|
478
|
+
return docs[0];
|
|
479
|
+
const setsByName = new Map();
|
|
480
|
+
for (const doc of docs) {
|
|
481
|
+
for (const set of doc.sets) {
|
|
482
|
+
const existing = setsByName.get(set.name);
|
|
483
|
+
if (!existing) {
|
|
484
|
+
setsByName.set(set.name, { ...set, tokens: [...set.tokens] });
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
existing.modes = [...new Set([...existing.modes, ...set.modes])];
|
|
488
|
+
// Dedupe by path: tokens with the same path merge their values.
|
|
489
|
+
// Critical for splitByMode output where each file has the same tokens
|
|
490
|
+
// with a different mode's value, and the import needs to reassemble
|
|
491
|
+
// them into one multi-mode token instead of triplicating.
|
|
492
|
+
const byPath = new Map(existing.tokens.map((t) => [t.path.join("/"), t]));
|
|
493
|
+
for (const incoming of set.tokens) {
|
|
494
|
+
const key = incoming.path.join("/");
|
|
495
|
+
const found = byPath.get(key);
|
|
496
|
+
if (found) {
|
|
497
|
+
found.values = { ...found.values, ...incoming.values };
|
|
498
|
+
// Merge MCP extensions too — newer lastSyncedAt wins, lastSyncedValue
|
|
499
|
+
// unions across modes.
|
|
500
|
+
const aExt = found.extensions?.["figma-console-mcp"];
|
|
501
|
+
const bExt = incoming.extensions?.["figma-console-mcp"];
|
|
502
|
+
if (aExt || bExt) {
|
|
503
|
+
const merged = { ...(aExt ?? {}), ...(bExt ?? {}) };
|
|
504
|
+
if (aExt?.lastSyncedValue || bExt?.lastSyncedValue) {
|
|
505
|
+
merged.lastSyncedValue = {
|
|
506
|
+
...(aExt?.lastSyncedValue ?? {}),
|
|
507
|
+
...(bExt?.lastSyncedValue ?? {}),
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
found.extensions = { ...(found.extensions ?? {}), "figma-console-mcp": merged };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
existing.tokens.push(incoming);
|
|
515
|
+
byPath.set(key, incoming);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
$schema: docs[0].$schema,
|
|
522
|
+
sets: [...setsByName.values()],
|
|
523
|
+
meta: docs[0].meta,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Compute a diff plan between Figma's current state (left) and the code's
|
|
528
|
+
* proposed state (right). Returns a structured diff plan; value-update
|
|
529
|
+
* mutations are applied via the plugin bridge below.
|
|
530
|
+
*/
|
|
531
|
+
function computeDiffPlan(figmaDoc, codeDoc) {
|
|
532
|
+
// Build lookup maps by path for both sides.
|
|
533
|
+
const figmaTokens = new Map();
|
|
534
|
+
for (const set of figmaDoc.sets) {
|
|
535
|
+
for (const t of set.tokens) {
|
|
536
|
+
figmaTokens.set(`${set.name}::${t.path.join(".")}`, t);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const codeTokens = new Map();
|
|
540
|
+
for (const set of codeDoc.sets) {
|
|
541
|
+
for (const t of set.tokens) {
|
|
542
|
+
codeTokens.set(`${set.name}::${t.path.join(".")}`, t);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const toCreate = [];
|
|
546
|
+
const toUpdate = [];
|
|
547
|
+
const toDelete = [];
|
|
548
|
+
let unchanged = 0;
|
|
549
|
+
for (const [key, codeToken] of codeTokens) {
|
|
550
|
+
const figmaToken = figmaTokens.get(key);
|
|
551
|
+
if (!figmaToken) {
|
|
552
|
+
toCreate.push({
|
|
553
|
+
path: key,
|
|
554
|
+
type: codeToken.type,
|
|
555
|
+
value: codeToken.values,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
else if (!valuesEqual(figmaToken.values, codeToken.values)) {
|
|
559
|
+
toUpdate.push({
|
|
560
|
+
path: key,
|
|
561
|
+
before: figmaToken.values,
|
|
562
|
+
after: codeToken.values,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
unchanged++;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
for (const key of figmaTokens.keys()) {
|
|
570
|
+
if (!codeTokens.has(key)) {
|
|
571
|
+
// Reports as "would delete if strategy=replace" but defaults to
|
|
572
|
+
// preserve under merge strategy.
|
|
573
|
+
toDelete.push({ path: key });
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return { toCreate, toUpdate, toDelete, unchanged };
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Structural equality for a token's mode-keyed values map. Order-independent
|
|
580
|
+
* so two tokens that have the same modes with the same values produce a
|
|
581
|
+
* match regardless of object insertion order.
|
|
582
|
+
*
|
|
583
|
+
* Recursive for composite values (typography, shadow) — those have nested
|
|
584
|
+
* objects too. Aliases are equal when both have the same `reference` string;
|
|
585
|
+
* literals are equal by deep value comparison.
|
|
586
|
+
*/
|
|
587
|
+
function valuesEqual(a, b) {
|
|
588
|
+
const aKeys = Object.keys(a).sort();
|
|
589
|
+
const bKeys = Object.keys(b).sort();
|
|
590
|
+
if (aKeys.length !== bKeys.length)
|
|
591
|
+
return false;
|
|
592
|
+
for (let i = 0; i < aKeys.length; i++) {
|
|
593
|
+
if (aKeys[i] !== bKeys[i])
|
|
594
|
+
return false;
|
|
595
|
+
if (!deepEqual(a[aKeys[i]], b[bKeys[i]]))
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
function deepEqual(a, b) {
|
|
601
|
+
if (a === b)
|
|
602
|
+
return true;
|
|
603
|
+
if (typeof a !== typeof b)
|
|
604
|
+
return false;
|
|
605
|
+
if (a === null || b === null)
|
|
606
|
+
return a === b;
|
|
607
|
+
if (typeof a !== "object")
|
|
608
|
+
return a === b;
|
|
609
|
+
if (Array.isArray(a)) {
|
|
610
|
+
if (!Array.isArray(b) || a.length !== b.length)
|
|
611
|
+
return false;
|
|
612
|
+
return a.every((v, i) => deepEqual(v, b[i]));
|
|
613
|
+
}
|
|
614
|
+
const aObj = a;
|
|
615
|
+
const bObj = b;
|
|
616
|
+
const aKeys = Object.keys(aObj).sort();
|
|
617
|
+
const bKeys = Object.keys(bObj).sort();
|
|
618
|
+
if (aKeys.length !== bKeys.length)
|
|
619
|
+
return false;
|
|
620
|
+
for (let i = 0; i < aKeys.length; i++) {
|
|
621
|
+
if (aKeys[i] !== bKeys[i])
|
|
622
|
+
return false;
|
|
623
|
+
if (!deepEqual(aObj[aKeys[i]], bObj[bKeys[i]]))
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
return true;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Build a quick lookup of (collectionId, modeName) → modeId from the raw
|
|
630
|
+
* Figma payload. Needed because our internal model is keyed by mode name
|
|
631
|
+
* but the Plugin API wants the modeId.
|
|
632
|
+
*/
|
|
633
|
+
function buildCollectionModeMap(payload) {
|
|
634
|
+
const out = new Map();
|
|
635
|
+
for (const c of payload.collections) {
|
|
636
|
+
const modes = new Map();
|
|
637
|
+
for (const m of c.modes ?? []) {
|
|
638
|
+
modes.set(m.name, m.modeId);
|
|
639
|
+
}
|
|
640
|
+
out.set(c.id, modes);
|
|
641
|
+
}
|
|
642
|
+
return out;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Convert a TokenValue back to Figma's native value shape. Required for the
|
|
646
|
+
* Plugin API's setValueForMode call.
|
|
647
|
+
*
|
|
648
|
+
* - color hex string "#RRGGBB(AA)" → { r, g, b, a } floats in [0, 1]
|
|
649
|
+
* - FLOAT-typed number → number
|
|
650
|
+
* - STRING-typed string → string
|
|
651
|
+
* - BOOLEAN → boolean
|
|
652
|
+
* - Alias references are not yet supported in the apply phase (would need
|
|
653
|
+
* to resolve the target variable ID); the discriminated return signals
|
|
654
|
+
* this case so the caller surfaces a warning rather than silently
|
|
655
|
+
* dropping the update.
|
|
656
|
+
*/
|
|
657
|
+
function tokenValueToFigma(value, resolvedType) {
|
|
658
|
+
if (value.reference) {
|
|
659
|
+
// A future minor version will resolve the referenced variable's Figma ID and emit
|
|
660
|
+
// { type: "VARIABLE_ALIAS", id }. For now, skip alias updates so we
|
|
661
|
+
// don't accidentally wipe a reference with a literal — and surface a
|
|
662
|
+
// warning so the user knows the diff plan promised an update that
|
|
663
|
+
// didn't actually apply.
|
|
664
|
+
return { kind: "skip-alias", reference: value.reference };
|
|
665
|
+
}
|
|
666
|
+
if (value.literal === undefined || value.literal === null) {
|
|
667
|
+
return { kind: "skip-empty" };
|
|
668
|
+
}
|
|
669
|
+
let figmaValue;
|
|
670
|
+
if (resolvedType === "COLOR" && typeof value.literal === "string") {
|
|
671
|
+
figmaValue = hexToRgba(value.literal);
|
|
672
|
+
}
|
|
673
|
+
else if (resolvedType === "FLOAT") {
|
|
674
|
+
figmaValue = typeof value.literal === "number" ? value.literal : Number(value.literal);
|
|
675
|
+
}
|
|
676
|
+
else if (resolvedType === "BOOLEAN") {
|
|
677
|
+
figmaValue = Boolean(value.literal);
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
figmaValue = typeof value.literal === "string" ? value.literal : String(value.literal);
|
|
681
|
+
}
|
|
682
|
+
return { kind: "value", value: figmaValue };
|
|
683
|
+
}
|
|
684
|
+
function hexToRgba(hex) {
|
|
685
|
+
const cleaned = hex.replace(/^#/, "");
|
|
686
|
+
let r;
|
|
687
|
+
let g;
|
|
688
|
+
let b;
|
|
689
|
+
let a = 1;
|
|
690
|
+
if (cleaned.length === 3) {
|
|
691
|
+
r = parseInt(cleaned[0] + cleaned[0], 16) / 255;
|
|
692
|
+
g = parseInt(cleaned[1] + cleaned[1], 16) / 255;
|
|
693
|
+
b = parseInt(cleaned[2] + cleaned[2], 16) / 255;
|
|
694
|
+
}
|
|
695
|
+
else if (cleaned.length === 6) {
|
|
696
|
+
r = parseInt(cleaned.slice(0, 2), 16) / 255;
|
|
697
|
+
g = parseInt(cleaned.slice(2, 4), 16) / 255;
|
|
698
|
+
b = parseInt(cleaned.slice(4, 6), 16) / 255;
|
|
699
|
+
}
|
|
700
|
+
else if (cleaned.length === 8) {
|
|
701
|
+
r = parseInt(cleaned.slice(0, 2), 16) / 255;
|
|
702
|
+
g = parseInt(cleaned.slice(2, 4), 16) / 255;
|
|
703
|
+
b = parseInt(cleaned.slice(4, 6), 16) / 255;
|
|
704
|
+
a = parseInt(cleaned.slice(6, 8), 16) / 255;
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
throw new Error(`[figma-console-mcp] Invalid hex color "${hex}" — expected 3, 6, or 8 hex digits.`);
|
|
708
|
+
}
|
|
709
|
+
return { r, g, b, a };
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Walk the toUpdate diff entries and translate each into a VariableUpdate.
|
|
713
|
+
* Tokens that lack a Figma variable ID (never been synced) or have no
|
|
714
|
+
* resolvable value for any mode get skipped with a warning.
|
|
715
|
+
*/
|
|
716
|
+
function buildUpdatePayloads(toUpdate, figmaDoc, codeDoc, collectionModeMap, warnings) {
|
|
717
|
+
// Build lookups: setName::tokenPath → (figmaToken, codeToken)
|
|
718
|
+
const figmaLookup = new Map();
|
|
719
|
+
for (const set of figmaDoc.sets) {
|
|
720
|
+
for (const t of set.tokens) {
|
|
721
|
+
figmaLookup.set(`${set.name}::${t.path.join(".")}`, { token: t, set });
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
const codeLookup = new Map();
|
|
725
|
+
for (const set of codeDoc.sets) {
|
|
726
|
+
for (const t of set.tokens) {
|
|
727
|
+
codeLookup.set(`${set.name}::${t.path.join(".")}`, { token: t, set });
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const updates = [];
|
|
731
|
+
for (const entry of toUpdate) {
|
|
732
|
+
const codeMatch = codeLookup.get(entry.path);
|
|
733
|
+
const figmaMatch = figmaLookup.get(entry.path);
|
|
734
|
+
if (!codeMatch || !figmaMatch)
|
|
735
|
+
continue;
|
|
736
|
+
const figmaToken = figmaMatch.token;
|
|
737
|
+
const variableId = figmaToken.extensions?.["figma-console-mcp"]?.variableId;
|
|
738
|
+
const collectionId = figmaToken.extensions?.["figma-console-mcp"]?.collectionId;
|
|
739
|
+
if (!variableId || !collectionId) {
|
|
740
|
+
warnings.push(`Cannot update ${entry.path} — missing Figma variable ID in extensions. Run figma_export_tokens first to populate.`);
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
const modeMap = collectionModeMap.get(collectionId);
|
|
744
|
+
if (!modeMap) {
|
|
745
|
+
warnings.push(`Cannot update ${entry.path} — collection ${collectionId} not found in current Figma state.`);
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
// Map our token type → Figma resolvedType. Both DTCG type names and
|
|
749
|
+
// Figma names need to align here.
|
|
750
|
+
const resolvedType = inferFigmaResolvedType(figmaToken.type);
|
|
751
|
+
const valuesByMode = {};
|
|
752
|
+
for (const [modeName, value] of Object.entries(codeMatch.token.values)) {
|
|
753
|
+
const modeId = modeMap.get(modeName);
|
|
754
|
+
if (!modeId) {
|
|
755
|
+
warnings.push(`Cannot update ${entry.path} (mode "${modeName}") — modeId not found in Figma collection.`);
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
const conversion = tokenValueToFigma(value, resolvedType);
|
|
759
|
+
if (conversion.kind === "skip-alias") {
|
|
760
|
+
warnings.push(`Skipped ${entry.path} (mode "${modeName}") — alias reference "${conversion.reference}" updates are not yet supported in the apply phase. To update this token, edit the alias target's value instead, or hard-code a literal hex value in the source file.`);
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (conversion.kind === "skip-empty")
|
|
764
|
+
continue;
|
|
765
|
+
valuesByMode[modeId] = conversion.value;
|
|
766
|
+
}
|
|
767
|
+
if (Object.keys(valuesByMode).length === 0)
|
|
768
|
+
continue;
|
|
769
|
+
updates.push({
|
|
770
|
+
variableId,
|
|
771
|
+
variableName: figmaToken.path.join("/"),
|
|
772
|
+
resolvedType,
|
|
773
|
+
valuesByMode,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
return updates;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Map our internal TokenType to Figma's variable resolvedType. The Plugin API
|
|
780
|
+
* only has 4 resolved types — collapse our richer set onto them.
|
|
781
|
+
*/
|
|
782
|
+
function inferFigmaResolvedType(type) {
|
|
783
|
+
if (type === "color")
|
|
784
|
+
return "COLOR";
|
|
785
|
+
if (type === "boolean")
|
|
786
|
+
return "BOOLEAN";
|
|
787
|
+
if (type === "string" || type === "fontFamily")
|
|
788
|
+
return "STRING";
|
|
789
|
+
return "FLOAT"; // dimension, number, fontWeight, duration, etc.
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Push variable updates to Figma via executeCodeViaUI. The plugin runs the
|
|
793
|
+
* inline script in its sandbox, calling figma.variables.setValueForMode for
|
|
794
|
+
* each (variableId, modeId, value) tuple.
|
|
795
|
+
*/
|
|
796
|
+
async function applyUpdates(connector, updates) {
|
|
797
|
+
// Serialize the update list into the script payload. JSON.stringify
|
|
798
|
+
// handles escape correctly even with nested objects (RGBA color values).
|
|
799
|
+
const payload = JSON.stringify(updates);
|
|
800
|
+
const script = `
|
|
801
|
+
const updates = ${payload};
|
|
802
|
+
const results = [];
|
|
803
|
+
for (const u of updates) {
|
|
804
|
+
try {
|
|
805
|
+
const variable = await figma.variables.getVariableByIdAsync(u.variableId);
|
|
806
|
+
if (!variable) {
|
|
807
|
+
results.push({ id: u.variableId, success: false, error: "Variable not found in current file" });
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
let appliedModes = 0;
|
|
811
|
+
for (const modeId in u.valuesByMode) {
|
|
812
|
+
variable.setValueForMode(modeId, u.valuesByMode[modeId]);
|
|
813
|
+
appliedModes++;
|
|
814
|
+
}
|
|
815
|
+
results.push({ id: u.variableId, name: variable.name, success: true, appliedModes });
|
|
816
|
+
} catch (err) {
|
|
817
|
+
results.push({ id: u.variableId, success: false, error: String(err && err.message || err) });
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return {
|
|
821
|
+
applied: results.filter(r => r.success).length,
|
|
822
|
+
failed: results.filter(r => !r.success).length,
|
|
823
|
+
results,
|
|
824
|
+
};
|
|
825
|
+
`;
|
|
826
|
+
const execResult = await connector.executeCodeViaUI(script, 30000);
|
|
827
|
+
if (!execResult?.success) {
|
|
828
|
+
return {
|
|
829
|
+
applied: 0,
|
|
830
|
+
failed: updates.length,
|
|
831
|
+
errors: [
|
|
832
|
+
{
|
|
833
|
+
variableId: "<batch>",
|
|
834
|
+
error: execResult?.error ??
|
|
835
|
+
"Plugin executeCodeViaUI returned an error or timed out.",
|
|
836
|
+
},
|
|
837
|
+
],
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
const inner = execResult.result ?? execResult;
|
|
841
|
+
const errors = (inner.results ?? [])
|
|
842
|
+
.filter((r) => !r.success)
|
|
843
|
+
.map((r) => ({ variableId: r.id, error: r.error }));
|
|
844
|
+
return {
|
|
845
|
+
applied: inner.applied ?? 0,
|
|
846
|
+
failed: inner.failed ?? 0,
|
|
847
|
+
errors,
|
|
848
|
+
};
|
|
849
|
+
}
|