@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,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alias reference resolution and validation.
|
|
3
|
+
*
|
|
4
|
+
* DTCG alias references look like `{color.primary}` or `{tier-1.color.blue.500}`.
|
|
5
|
+
* They can chain (an alias can reference another alias), but cycles are an
|
|
6
|
+
* error.
|
|
7
|
+
*
|
|
8
|
+
* This module:
|
|
9
|
+
* - Resolves an alias to its eventual literal value (for formatters that
|
|
10
|
+
* can't natively express references — CSS, SCSS, Tailwind).
|
|
11
|
+
* - Validates that every alias points to an existing token.
|
|
12
|
+
* - Detects cycles.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Build a lookup map from dot-path strings (e.g. "color.primary") to Token
|
|
16
|
+
* objects. Used as the index for resolveAliases().
|
|
17
|
+
*/
|
|
18
|
+
export function buildTokenIndex(doc) {
|
|
19
|
+
const index = new Map();
|
|
20
|
+
for (const set of doc.sets) {
|
|
21
|
+
for (const token of set.tokens) {
|
|
22
|
+
const key = token.path.join(".");
|
|
23
|
+
index.set(key, token);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return index;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a single alias reference. Returns the eventual literal value, or
|
|
30
|
+
* throws if the reference is unresolvable or cyclic.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveReference(reference, mode, index, seen = new Set()) {
|
|
33
|
+
// Strip the curly braces if present: "{color.primary}" → "color.primary"
|
|
34
|
+
const path = reference.replace(/^\{|\}$/g, "");
|
|
35
|
+
if (seen.has(path)) {
|
|
36
|
+
throw new Error(`[figma-console-mcp] Alias cycle detected: ${[...seen, path].join(" → ")}`);
|
|
37
|
+
}
|
|
38
|
+
seen.add(path);
|
|
39
|
+
const target = index.get(path);
|
|
40
|
+
if (!target) {
|
|
41
|
+
throw new Error(`[figma-console-mcp] Unresolvable alias reference: {${path}}`);
|
|
42
|
+
}
|
|
43
|
+
// Find the value for the requested mode; fall back to the only mode if
|
|
44
|
+
// there's just one (alias's target may be single-mode while the source
|
|
45
|
+
// is multi-mode, or vice versa).
|
|
46
|
+
const value = target.values[mode] ??
|
|
47
|
+
(Object.keys(target.values).length === 1
|
|
48
|
+
? Object.values(target.values)[0]
|
|
49
|
+
: undefined);
|
|
50
|
+
if (!value) {
|
|
51
|
+
throw new Error(`[figma-console-mcp] Alias {${path}} has no value for mode "${mode}"`);
|
|
52
|
+
}
|
|
53
|
+
// Chain: if the target's value is itself an alias, recurse.
|
|
54
|
+
if (value.reference) {
|
|
55
|
+
return resolveReference(value.reference, mode, index, seen);
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Validate every alias in the document. Returns a list of error messages —
|
|
61
|
+
* empty array means all aliases resolve cleanly.
|
|
62
|
+
*/
|
|
63
|
+
export function validateAliases(doc) {
|
|
64
|
+
const index = buildTokenIndex(doc);
|
|
65
|
+
const errors = [];
|
|
66
|
+
for (const set of doc.sets) {
|
|
67
|
+
for (const token of set.tokens) {
|
|
68
|
+
for (const [mode, value] of Object.entries(token.values)) {
|
|
69
|
+
if (value.reference) {
|
|
70
|
+
try {
|
|
71
|
+
resolveReference(value.reference, mode, index);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
errors.push(`${token.path.join(".")} (mode "${mode}"): ${err instanceof Error ? err.message : String(err)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return errors;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Format an alias reference for DTCG output. DTCG uses `{path.to.token}`
|
|
84
|
+
* syntax with curly braces.
|
|
85
|
+
*/
|
|
86
|
+
export function formatDtcgReference(referencePath) {
|
|
87
|
+
return `{${referencePath.join(".")}}`;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Parse a DTCG alias string back into a path array. Returns null if the
|
|
91
|
+
* string isn't an alias reference.
|
|
92
|
+
*/
|
|
93
|
+
export function parseDtcgReference(s) {
|
|
94
|
+
const match = s.match(/^\{([^}]+)\}$/);
|
|
95
|
+
if (!match)
|
|
96
|
+
return null;
|
|
97
|
+
return match[1].split(".");
|
|
98
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tokens.config.json schema, loader, and autodiscovery for the figma-console-mcp
|
|
3
|
+
* token sync engine.
|
|
4
|
+
*
|
|
5
|
+
* Both figma_export_tokens and figma_import_tokens read this single config so
|
|
6
|
+
* follow-up calls in a project are zero-arg. Autodiscovery walks up from the
|
|
7
|
+
* current working directory looking for `tokens.config.json` at each level
|
|
8
|
+
* — same convention as `tsconfig.json`, `package.json`, `.eslintrc`, etc.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join, resolve } from "node:path";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
/**
|
|
14
|
+
* Schema for a single output target in `tokens.config.json`. Each entry
|
|
15
|
+
* produces one or more files when figma_export_tokens runs.
|
|
16
|
+
*/
|
|
17
|
+
const OutputTargetSchema = z.object({
|
|
18
|
+
format: z.enum([
|
|
19
|
+
"dtcg",
|
|
20
|
+
"tokens-studio",
|
|
21
|
+
"css-vars",
|
|
22
|
+
"tailwind-v4",
|
|
23
|
+
"tailwind-v3",
|
|
24
|
+
"scss",
|
|
25
|
+
"less",
|
|
26
|
+
"ts-module",
|
|
27
|
+
"json-flat",
|
|
28
|
+
"json-nested",
|
|
29
|
+
"style-dictionary-v3",
|
|
30
|
+
]),
|
|
31
|
+
/** Optional filename override. Default is derived from format + scope. */
|
|
32
|
+
filename: z.string().optional(),
|
|
33
|
+
/** Output prefix applied to every token name (e.g. "ds-", "al-"). */
|
|
34
|
+
prefix: z.string().optional(),
|
|
35
|
+
/** Emit one file per mode (e.g. tokens-light.css, tokens-dark.css). */
|
|
36
|
+
splitByMode: z.boolean().optional(),
|
|
37
|
+
/** Emit one file per token set / Figma collection. */
|
|
38
|
+
splitByCollection: z.boolean().optional(),
|
|
39
|
+
/**
|
|
40
|
+
* If true, alias references are resolved to literal values in the output.
|
|
41
|
+
* If false, aliases are preserved (default for JSON formats, forced true
|
|
42
|
+
* for CSS/SCSS/Tailwind/etc. since they can't natively express aliases).
|
|
43
|
+
*/
|
|
44
|
+
resolveAliases: z.boolean().optional(),
|
|
45
|
+
/** Per-target transform options. Override the global defaults. */
|
|
46
|
+
transforms: z
|
|
47
|
+
.object({
|
|
48
|
+
colorFormat: z.enum(["hex", "hex8", "rgba", "oklch", "hsl"]).optional(),
|
|
49
|
+
sizeUnit: z.enum(["px", "rem", "pt", "dp"]).optional(),
|
|
50
|
+
remBase: z.number().positive().optional(),
|
|
51
|
+
})
|
|
52
|
+
.optional(),
|
|
53
|
+
});
|
|
54
|
+
/**
|
|
55
|
+
* Full schema for `tokens.config.json`. Every field is optional so the
|
|
56
|
+
* minimum-viable config is `{ "figmaFile": "..." }` — the rest gets sensible
|
|
57
|
+
* defaults.
|
|
58
|
+
*/
|
|
59
|
+
export const TokensConfigSchema = z
|
|
60
|
+
.object({
|
|
61
|
+
/** Optional JSON Schema URL for editor autocompletion. */
|
|
62
|
+
$schema: z.string().optional(),
|
|
63
|
+
/**
|
|
64
|
+
* Figma file URL or fileKey. When omitted, tools fall back to the
|
|
65
|
+
* currently-connected Desktop Bridge plugin's file (Local Mode) or the
|
|
66
|
+
* file context bound by figma_pair_plugin (Cloud Mode).
|
|
67
|
+
*/
|
|
68
|
+
figmaFile: z.string().optional(),
|
|
69
|
+
/** Where the canonical (committed) token sources live. */
|
|
70
|
+
source: z
|
|
71
|
+
.object({
|
|
72
|
+
/** Directory holding the canonical token files. */
|
|
73
|
+
dir: z.string(),
|
|
74
|
+
/** Glob pattern within dir. Default: "*.tokens.json" */
|
|
75
|
+
pattern: z.string().optional(),
|
|
76
|
+
/**
|
|
77
|
+
* Canonical format for source files. DTCG is the recommended default;
|
|
78
|
+
* Tokens Studio is supported for users who already have a `$themes.json`
|
|
79
|
+
* setup (e.g. Altitude).
|
|
80
|
+
*/
|
|
81
|
+
canonical: z.enum(["dtcg", "tokens-studio"]).default("dtcg"),
|
|
82
|
+
})
|
|
83
|
+
.default({ dir: "src/styles/tokens", canonical: "dtcg" }),
|
|
84
|
+
/** Where build outputs (CSS, Tailwind, etc.) get written. */
|
|
85
|
+
generated: z
|
|
86
|
+
.object({
|
|
87
|
+
dir: z.string().default("src/styles/generated"),
|
|
88
|
+
formats: z.array(OutputTargetSchema).default([]),
|
|
89
|
+
})
|
|
90
|
+
.optional(),
|
|
91
|
+
/** Mode name mappings (Figma mode name → output mode name). */
|
|
92
|
+
modes: z
|
|
93
|
+
.object({
|
|
94
|
+
/** e.g. { "Light": "light", "Dark": "dark" } */
|
|
95
|
+
map: z.record(z.string()).optional(),
|
|
96
|
+
/** Default mode if a token has no explicit mode (e.g. "Light"). */
|
|
97
|
+
default: z.string().optional(),
|
|
98
|
+
})
|
|
99
|
+
.optional(),
|
|
100
|
+
/** Default conflict-resolution strategy when not specified per-call. */
|
|
101
|
+
conflictResolution: z
|
|
102
|
+
.enum(["ask", "figma-wins", "code-wins", "skip"])
|
|
103
|
+
.default("ask"),
|
|
104
|
+
/** Behavior for tokens that exist on one side but not the other. */
|
|
105
|
+
sync: z
|
|
106
|
+
.object({
|
|
107
|
+
onMissingInCode: z
|
|
108
|
+
.enum(["preserve", "delete", "warn"])
|
|
109
|
+
.default("preserve"),
|
|
110
|
+
onMissingInFigma: z
|
|
111
|
+
.enum(["preserve", "delete", "warn"])
|
|
112
|
+
.default("preserve"),
|
|
113
|
+
})
|
|
114
|
+
.optional(),
|
|
115
|
+
})
|
|
116
|
+
.strict();
|
|
117
|
+
/**
|
|
118
|
+
* Walk up from `startDir` looking for `tokens.config.json`. Returns the first
|
|
119
|
+
* match, or `null` if none found by the filesystem root.
|
|
120
|
+
*/
|
|
121
|
+
export function findTokensConfig(startDir) {
|
|
122
|
+
let dir = resolve(startDir);
|
|
123
|
+
// Hard cap on directory traversal so a misconfigured startDir can't loop
|
|
124
|
+
// forever (defense against symlinks/weird filesystems).
|
|
125
|
+
const maxDepth = 32;
|
|
126
|
+
for (let i = 0; i < maxDepth; i++) {
|
|
127
|
+
const candidate = join(dir, "tokens.config.json");
|
|
128
|
+
if (existsSync(candidate)) {
|
|
129
|
+
return candidate;
|
|
130
|
+
}
|
|
131
|
+
const parent = dirname(dir);
|
|
132
|
+
if (parent === dir) {
|
|
133
|
+
// Hit filesystem root, no config found.
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
dir = parent;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Load and validate `tokens.config.json`. If `explicitPath` is provided, uses
|
|
142
|
+
* that; otherwise autodiscovers by walking up from `cwd` (default
|
|
143
|
+
* `process.cwd()`).
|
|
144
|
+
*
|
|
145
|
+
* Returns `null` if no config is found AND no explicit path was given. Throws
|
|
146
|
+
* if an explicit path doesn't exist, or if the discovered file fails schema
|
|
147
|
+
* validation.
|
|
148
|
+
*/
|
|
149
|
+
export function loadTokensConfig(opts = {}) {
|
|
150
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
151
|
+
const configPath = opts.explicitPath
|
|
152
|
+
? resolve(opts.explicitPath)
|
|
153
|
+
: findTokensConfig(cwd);
|
|
154
|
+
if (!configPath)
|
|
155
|
+
return null;
|
|
156
|
+
if (!existsSync(configPath)) {
|
|
157
|
+
throw new Error(`[figma-console-mcp] tokens.config.json not found at ${configPath}`);
|
|
158
|
+
}
|
|
159
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
160
|
+
let parsed;
|
|
161
|
+
try {
|
|
162
|
+
parsed = JSON.parse(raw);
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
throw new Error(`[figma-console-mcp] tokens.config.json at ${configPath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
166
|
+
}
|
|
167
|
+
const result = TokensConfigSchema.safeParse(parsed);
|
|
168
|
+
if (!result.success) {
|
|
169
|
+
const issues = result.error.issues
|
|
170
|
+
.map((i) => ` • ${i.path.join(".")}: ${i.message}`)
|
|
171
|
+
.join("\n");
|
|
172
|
+
throw new Error(`[figma-console-mcp] tokens.config.json at ${configPath} failed validation:\n${issues}`);
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
config: result.data,
|
|
176
|
+
configPath,
|
|
177
|
+
projectRoot: dirname(configPath),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Default config used when none is found. Drives the "no-config detected"
|
|
182
|
+
* response shape from figma_export_tokens — the AI uses this to propose a
|
|
183
|
+
* scaffold to the user.
|
|
184
|
+
*/
|
|
185
|
+
export const DEFAULT_TOKENS_CONFIG = {
|
|
186
|
+
source: { dir: "src/styles/tokens", canonical: "dtcg" },
|
|
187
|
+
generated: {
|
|
188
|
+
dir: "src/styles/generated",
|
|
189
|
+
formats: [
|
|
190
|
+
{ format: "css-vars", splitByMode: true },
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
conflictResolution: "ask",
|
|
194
|
+
};
|
|
195
|
+
/**
|
|
196
|
+
* Build a `suggestedScaffold` payload returned when a tool is called and no
|
|
197
|
+
* `tokens.config.json` exists. The AI presents this scaffold to the user,
|
|
198
|
+
* writes the files via its native edit/write tools, then calls the original
|
|
199
|
+
* tool again.
|
|
200
|
+
*/
|
|
201
|
+
export function buildSuggestedScaffold(opts) {
|
|
202
|
+
const config = {
|
|
203
|
+
$schema: "https://figma-console-mcp.southleft.com/schemas/tokens.config.v1.json",
|
|
204
|
+
source: { dir: "src/styles/tokens", canonical: "dtcg" },
|
|
205
|
+
generated: {
|
|
206
|
+
dir: "src/styles/generated",
|
|
207
|
+
formats: pickStartingFormats(opts.detectedFramework),
|
|
208
|
+
},
|
|
209
|
+
conflictResolution: "ask",
|
|
210
|
+
};
|
|
211
|
+
const stylesheetImport = pickStylesheetImport(opts.detectedFramework);
|
|
212
|
+
return {
|
|
213
|
+
configContent: JSON.stringify(config, null, 2),
|
|
214
|
+
directories: [config.source.dir, config.generated?.dir ?? "src/styles/generated"],
|
|
215
|
+
stylesheetImport,
|
|
216
|
+
nextSteps: [
|
|
217
|
+
"1. Write `tokens.config.json` at the project root using `configContent`.",
|
|
218
|
+
`2. Create the directories: ${config.source.dir} and ${config.generated?.dir}.`,
|
|
219
|
+
`3. Add this line to your main stylesheet:\n ${stylesheetImport}`,
|
|
220
|
+
"4. Run `figma_export_tokens` again — it'll pick up the new config and populate the source dir.",
|
|
221
|
+
].join("\n"),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function pickStartingFormats(framework) {
|
|
225
|
+
// Always emit DTCG as the canonical committed source; layer the
|
|
226
|
+
// framework-appropriate runtime format on top.
|
|
227
|
+
const base = [];
|
|
228
|
+
switch (framework) {
|
|
229
|
+
case "tailwind-v4":
|
|
230
|
+
base.push({ format: "tailwind-v4", splitByMode: true });
|
|
231
|
+
break;
|
|
232
|
+
case "tailwind-v3":
|
|
233
|
+
base.push({ format: "tailwind-v3" });
|
|
234
|
+
break;
|
|
235
|
+
case "scss":
|
|
236
|
+
base.push({ format: "scss", splitByMode: true });
|
|
237
|
+
break;
|
|
238
|
+
case "ts":
|
|
239
|
+
base.push({ format: "ts-module" });
|
|
240
|
+
base.push({ format: "css-vars", splitByMode: true });
|
|
241
|
+
break;
|
|
242
|
+
case "css":
|
|
243
|
+
default:
|
|
244
|
+
base.push({ format: "css-vars", splitByMode: true });
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
return base;
|
|
248
|
+
}
|
|
249
|
+
function pickStylesheetImport(framework) {
|
|
250
|
+
switch (framework) {
|
|
251
|
+
case "tailwind-v4":
|
|
252
|
+
return "@import './styles/generated/tailwind.theme.css';";
|
|
253
|
+
case "scss":
|
|
254
|
+
return "@use './styles/generated/tokens.scss' as *;";
|
|
255
|
+
case "ts":
|
|
256
|
+
case "css":
|
|
257
|
+
default:
|
|
258
|
+
return "@import './styles/generated/tokens.css';";
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Pick the export formats from a loaded config that map to a given runtime
|
|
263
|
+
* format. Used by figma_export_tokens to decide which generated files to
|
|
264
|
+
* write. Returns the list verbatim if the caller passed an explicit format.
|
|
265
|
+
*/
|
|
266
|
+
export function resolveOutputTargets(config, explicitFormat) {
|
|
267
|
+
if (explicitFormat) {
|
|
268
|
+
// Caller specified a format directly; ignore config's generated list.
|
|
269
|
+
return [{ format: explicitFormat }];
|
|
270
|
+
}
|
|
271
|
+
if (!config?.generated?.formats?.length) {
|
|
272
|
+
// No formats configured. Default to DTCG only — produces the canonical
|
|
273
|
+
// source files but no derived runtime outputs.
|
|
274
|
+
return [{ format: "dtcg" }];
|
|
275
|
+
}
|
|
276
|
+
return config.generated.formats;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Resolve the conflict-resolution strategy. Per-call argument wins over config
|
|
280
|
+
* default, which wins over the global default ("ask").
|
|
281
|
+
*/
|
|
282
|
+
export function resolveConflictStrategy(config, perCall) {
|
|
283
|
+
return perCall ?? config?.conflictResolution ?? "ask";
|
|
284
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert Figma's variables API response into our canonical TokenDocument.
|
|
3
|
+
*
|
|
4
|
+
* Input: the shape produced by `formatVariables()` in src/core/figma-api.ts
|
|
5
|
+
* (an object with `collections` and `variables` arrays, matching either the
|
|
6
|
+
* REST API's response or the Desktop Bridge plugin's `getLocalVariablesAsync`
|
|
7
|
+
* normalized payload).
|
|
8
|
+
*
|
|
9
|
+
* Output: a TokenDocument with one TokenSet per collection, paths derived
|
|
10
|
+
* from Figma variable names (slash-separated → path arrays), values
|
|
11
|
+
* normalized to our TokenValue shape, and Figma IDs preserved in
|
|
12
|
+
* $extensions["figma-console-mcp"] for round-trip non-destructiveness.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Convert a Figma variables payload to our canonical TokenDocument.
|
|
16
|
+
*/
|
|
17
|
+
export function convertFigmaVariablesToDocument(payload, opts = {}) {
|
|
18
|
+
const warnings = [];
|
|
19
|
+
// Build a variable index for alias resolution: variableId → variable
|
|
20
|
+
const variableById = new Map();
|
|
21
|
+
for (const v of payload.variables)
|
|
22
|
+
variableById.set(v.id, v);
|
|
23
|
+
// Filter collections per scope.
|
|
24
|
+
const wantedCollections = opts.collectionIds?.length
|
|
25
|
+
? payload.collections.filter((c) => opts.collectionIds.includes(c.id))
|
|
26
|
+
: payload.collections;
|
|
27
|
+
const sets = wantedCollections.map((collection) => convertCollection(collection, payload.variables, variableById, opts, warnings));
|
|
28
|
+
return {
|
|
29
|
+
document: {
|
|
30
|
+
$schema: "https://figma-console-mcp.southleft.com/schemas/dtcg-extended-v1.json",
|
|
31
|
+
sets,
|
|
32
|
+
meta: {
|
|
33
|
+
figmaFileKey: opts.figmaFileKey,
|
|
34
|
+
exportedAt: opts.exportedAt ?? new Date().toISOString(),
|
|
35
|
+
mcpVersion: opts.mcpVersion,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
warnings,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function convertCollection(collection, allVariables, variableById, opts, warnings) {
|
|
42
|
+
// Mode filter: keep only modes the caller wants, intersected with what
|
|
43
|
+
// the collection actually has.
|
|
44
|
+
const wantedModes = !opts.modes || opts.modes === "all"
|
|
45
|
+
? collection.modes
|
|
46
|
+
: collection.modes.filter((m) => opts.modes.includes(m.name));
|
|
47
|
+
// Variables in this collection.
|
|
48
|
+
const collectionVars = allVariables.filter((v) => v.variableCollectionId === collection.id);
|
|
49
|
+
const tokens = collectionVars.map((variable) => convertVariable(variable, wantedModes, variableById, opts, warnings));
|
|
50
|
+
return {
|
|
51
|
+
name: collection.name,
|
|
52
|
+
modes: wantedModes.map((m) => m.name),
|
|
53
|
+
tokens,
|
|
54
|
+
meta: {
|
|
55
|
+
figmaCollectionId: collection.id,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function convertVariable(variable, wantedModes, variableById, opts, warnings) {
|
|
60
|
+
// Derive the hierarchical path from the Figma variable name. Figma uses
|
|
61
|
+
// slashes to indicate grouping: "color/brand/primary" → ["color", "brand", "primary"].
|
|
62
|
+
let name = variable.name;
|
|
63
|
+
if (opts.stripPrefix && name.startsWith(opts.stripPrefix)) {
|
|
64
|
+
name = name.slice(opts.stripPrefix.length);
|
|
65
|
+
}
|
|
66
|
+
const path = name.split("/").filter(Boolean);
|
|
67
|
+
// Map resolvedType to TokenType.
|
|
68
|
+
const type = mapResolvedType(variable.resolvedType, variable.name, warnings);
|
|
69
|
+
// Convert each (mode → value) pair to our TokenValue shape, filtered by
|
|
70
|
+
// the wanted modes.
|
|
71
|
+
const values = {};
|
|
72
|
+
for (const mode of wantedModes) {
|
|
73
|
+
const rawValue = variable.valuesByMode[mode.modeId];
|
|
74
|
+
if (rawValue === undefined) {
|
|
75
|
+
warnings.push(`Variable "${variable.name}" has no value for mode "${mode.name}" (${mode.modeId}); skipping that mode.`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
values[mode.name] = convertValue(rawValue, variable.resolvedType, variableById, warnings);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
path,
|
|
82
|
+
type,
|
|
83
|
+
description: variable.description || undefined,
|
|
84
|
+
values,
|
|
85
|
+
extensions: {
|
|
86
|
+
"figma-console-mcp": {
|
|
87
|
+
variableId: variable.id,
|
|
88
|
+
collectionId: variable.variableCollectionId,
|
|
89
|
+
lastSyncedAt: new Date().toISOString(),
|
|
90
|
+
// We snapshot the synced value so future merge calls can detect
|
|
91
|
+
// two-sided conflicts.
|
|
92
|
+
lastSyncedValue: { ...values },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function mapResolvedType(resolvedType, variableName, warnings) {
|
|
98
|
+
switch (resolvedType) {
|
|
99
|
+
case "COLOR":
|
|
100
|
+
return "color";
|
|
101
|
+
case "FLOAT":
|
|
102
|
+
// Figma FLOAT covers both pure numbers and dimensions. We default to
|
|
103
|
+
// "dimension" because the typical FLOAT variable represents spacing,
|
|
104
|
+
// sizing, or radius — all dimensions. A future enhancement could
|
|
105
|
+
// sniff the variable name (e.g. "opacity/*" → "number") for better
|
|
106
|
+
// type fidelity.
|
|
107
|
+
return inferFloatType(variableName);
|
|
108
|
+
case "STRING":
|
|
109
|
+
return inferStringType(variableName);
|
|
110
|
+
case "BOOLEAN":
|
|
111
|
+
return "boolean";
|
|
112
|
+
default: {
|
|
113
|
+
const _exhaustive = resolvedType;
|
|
114
|
+
warnings.push(`Unknown resolvedType "${_exhaustive}" for variable "${variableName}"; treating as string.`);
|
|
115
|
+
return "string";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function inferFloatType(variableName) {
|
|
120
|
+
const lower = variableName.toLowerCase();
|
|
121
|
+
if (lower.includes("opacity") || lower.includes("alpha"))
|
|
122
|
+
return "number";
|
|
123
|
+
if (lower.includes("font-weight") || lower.includes("weight"))
|
|
124
|
+
return "fontWeight";
|
|
125
|
+
if (lower.includes("duration") || lower.includes("delay"))
|
|
126
|
+
return "duration";
|
|
127
|
+
// Default: treat numeric variables as dimensions (px values).
|
|
128
|
+
return "dimension";
|
|
129
|
+
}
|
|
130
|
+
function inferStringType(variableName) {
|
|
131
|
+
const lower = variableName.toLowerCase();
|
|
132
|
+
if (lower.includes("font-family") || lower.includes("font/family"))
|
|
133
|
+
return "fontFamily";
|
|
134
|
+
return "string";
|
|
135
|
+
}
|
|
136
|
+
function convertValue(rawValue, resolvedType, variableById, warnings) {
|
|
137
|
+
// Alias references: convert variable ID → path-based reference for DTCG.
|
|
138
|
+
if (isVariableAlias(rawValue)) {
|
|
139
|
+
const target = variableById.get(rawValue.id);
|
|
140
|
+
if (!target) {
|
|
141
|
+
// Cross-library alias — target is in a published library this file
|
|
142
|
+
// consumes, not in the local variable set. Preserve the original
|
|
143
|
+
// Figma variable ID in the reference syntax so round-trip can
|
|
144
|
+
// recover it AND formatters can detect this is unresolvable (vs a
|
|
145
|
+
// genuine local-path alias).
|
|
146
|
+
warnings.push(`Alias to unknown variable ID ${rawValue.id} (likely a cross-library reference). Original ID preserved in reference for round-trip.`);
|
|
147
|
+
return { reference: `{__library:${rawValue.id}}` };
|
|
148
|
+
}
|
|
149
|
+
// The DTCG alias path uses dots: "color.brand.primary".
|
|
150
|
+
const dotPath = target.name.replace(/\//g, ".");
|
|
151
|
+
return { reference: `{${dotPath}}` };
|
|
152
|
+
}
|
|
153
|
+
// Literal values per type.
|
|
154
|
+
if (resolvedType === "COLOR") {
|
|
155
|
+
if (typeof rawValue === "object" && rawValue !== null && "r" in rawValue) {
|
|
156
|
+
return { literal: rgbaToHex(rawValue) };
|
|
157
|
+
}
|
|
158
|
+
warnings.push(`COLOR value isn't an RGB object: ${JSON.stringify(rawValue)}`);
|
|
159
|
+
return { literal: String(rawValue) };
|
|
160
|
+
}
|
|
161
|
+
if (resolvedType === "FLOAT") {
|
|
162
|
+
return { literal: typeof rawValue === "number" ? rawValue : Number(rawValue) };
|
|
163
|
+
}
|
|
164
|
+
if (resolvedType === "BOOLEAN") {
|
|
165
|
+
return { literal: Boolean(rawValue) };
|
|
166
|
+
}
|
|
167
|
+
// STRING and fallthrough.
|
|
168
|
+
return { literal: typeof rawValue === "string" ? rawValue : String(rawValue) };
|
|
169
|
+
}
|
|
170
|
+
function isVariableAlias(value) {
|
|
171
|
+
return (typeof value === "object" &&
|
|
172
|
+
value !== null &&
|
|
173
|
+
"type" in value &&
|
|
174
|
+
value.type === "VARIABLE_ALIAS");
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Convert Figma's `{r, g, b, a}` floats (0–1 range) to a hex string. Returns
|
|
178
|
+
* `#RRGGBB` when alpha is 1 (or absent), `#RRGGBBAA` when alpha < 1.
|
|
179
|
+
*/
|
|
180
|
+
function rgbaToHex(rgba) {
|
|
181
|
+
const r = clampByte(rgba.r);
|
|
182
|
+
const g = clampByte(rgba.g);
|
|
183
|
+
const b = clampByte(rgba.b);
|
|
184
|
+
const a = rgba.a ?? 1;
|
|
185
|
+
const hex = `#${byteToHex(r)}${byteToHex(g)}${byteToHex(b)}`;
|
|
186
|
+
if (a >= 1)
|
|
187
|
+
return hex;
|
|
188
|
+
return `${hex}${byteToHex(clampByte(a))}`;
|
|
189
|
+
}
|
|
190
|
+
function clampByte(f) {
|
|
191
|
+
return Math.max(0, Math.min(255, Math.round(f * 255)));
|
|
192
|
+
}
|
|
193
|
+
function byteToHex(byte) {
|
|
194
|
+
return byte.toString(16).padStart(2, "0").toUpperCase();
|
|
195
|
+
}
|