@muloka/figma-console-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +898 -0
- package/dist/apps/design-system-dashboard/scoring/accessibility.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/accessibility.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/accessibility.js +278 -0
- package/dist/apps/design-system-dashboard/scoring/accessibility.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/component-metadata.d.ts +29 -0
- package/dist/apps/design-system-dashboard/scoring/component-metadata.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/component-metadata.js +358 -0
- package/dist/apps/design-system-dashboard/scoring/component-metadata.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/consistency.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/consistency.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/consistency.js +342 -0
- package/dist/apps/design-system-dashboard/scoring/consistency.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/coverage.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/coverage.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/coverage.js +231 -0
- package/dist/apps/design-system-dashboard/scoring/coverage.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/engine.d.ts +27 -0
- package/dist/apps/design-system-dashboard/scoring/engine.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/engine.js +93 -0
- package/dist/apps/design-system-dashboard/scoring/engine.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/naming-semantics.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/naming-semantics.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/naming-semantics.js +309 -0
- package/dist/apps/design-system-dashboard/scoring/naming-semantics.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/token-architecture.d.ts +14 -0
- package/dist/apps/design-system-dashboard/scoring/token-architecture.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/token-architecture.js +350 -0
- package/dist/apps/design-system-dashboard/scoring/token-architecture.js.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/types.d.ts +89 -0
- package/dist/apps/design-system-dashboard/scoring/types.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/scoring/types.js +41 -0
- package/dist/apps/design-system-dashboard/scoring/types.js.map +1 -0
- package/dist/apps/design-system-dashboard/server.d.ts +24 -0
- package/dist/apps/design-system-dashboard/server.d.ts.map +1 -0
- package/dist/apps/design-system-dashboard/server.js +160 -0
- package/dist/apps/design-system-dashboard/server.js.map +1 -0
- package/dist/apps/token-browser/server.d.ts +26 -0
- package/dist/apps/token-browser/server.d.ts.map +1 -0
- package/dist/apps/token-browser/server.js +137 -0
- package/dist/apps/token-browser/server.js.map +1 -0
- package/dist/browser/base.d.ts +58 -0
- package/dist/browser/base.d.ts.map +1 -0
- package/dist/browser/base.js +6 -0
- package/dist/browser/base.js.map +1 -0
- package/dist/browser/local.d.ts +87 -0
- package/dist/browser/local.d.ts.map +1 -0
- package/dist/browser/local.js +318 -0
- package/dist/browser/local.js.map +1 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/accessibility.js +277 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/component-metadata.js +357 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/consistency.js +341 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/coverage.js +230 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/engine.js +92 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/naming-semantics.js +308 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/token-architecture.js +349 -0
- package/dist/cloudflare/apps/design-system-dashboard/scoring/types.js +40 -0
- package/dist/cloudflare/apps/design-system-dashboard/server.js +159 -0
- package/dist/cloudflare/apps/token-browser/server.js +136 -0
- package/dist/cloudflare/browser-manager.js +157 -0
- package/dist/cloudflare/core/accessibility-tools.js +361 -0
- package/dist/cloudflare/core/annotation-tools.js +230 -0
- package/dist/cloudflare/core/cloud-websocket-connector.js +419 -0
- package/dist/cloudflare/core/cloud-websocket-relay.js +198 -0
- package/dist/cloudflare/core/comment-tools.js +292 -0
- package/dist/cloudflare/core/config.js +153 -0
- package/dist/cloudflare/core/console-monitor.js +427 -0
- package/dist/cloudflare/core/deep-component-tools.js +128 -0
- package/dist/cloudflare/core/design-code-tools.js +2793 -0
- package/dist/cloudflare/core/design-system-manifest.js +265 -0
- package/dist/cloudflare/core/design-system-tools.js +882 -0
- package/dist/cloudflare/core/diagnose-tool.js +100 -0
- package/dist/cloudflare/core/diff/changelog-formatter.js +275 -0
- package/dist/cloudflare/core/diff/diff-engine.js +334 -0
- package/dist/cloudflare/core/diff/property-compare.js +36 -0
- package/dist/cloudflare/core/diff/version-cache.js +74 -0
- package/dist/cloudflare/core/enrichment/enrichment-service.js +374 -0
- package/dist/cloudflare/core/enrichment/index.js +7 -0
- package/dist/cloudflare/core/enrichment/relationship-mapper.js +351 -0
- package/dist/cloudflare/core/enrichment/style-resolver.js +346 -0
- package/dist/cloudflare/core/figjam-tools.js +547 -0
- package/dist/cloudflare/core/figma-api.js +509 -0
- package/dist/cloudflare/core/figma-connector.js +7 -0
- package/dist/cloudflare/core/figma-reconstruction-spec.js +402 -0
- package/dist/cloudflare/core/figma-style-extractor.js +311 -0
- package/dist/cloudflare/core/figma-tools.js +3302 -0
- package/dist/cloudflare/core/identity.js +96 -0
- package/dist/cloudflare/core/library-tools.js +580 -0
- package/dist/cloudflare/core/logger.js +53 -0
- package/dist/cloudflare/core/port-discovery.js +935 -0
- package/dist/cloudflare/core/resolve-package-root.js +11 -0
- package/dist/cloudflare/core/slides-tools.js +714 -0
- package/dist/cloudflare/core/slot-tools.js +334 -0
- package/dist/cloudflare/core/snippet-injector.js +96 -0
- package/dist/cloudflare/core/tokens/alias-resolver.js +205 -0
- package/dist/cloudflare/core/tokens/config.js +294 -0
- package/dist/cloudflare/core/tokens/dialect.js +232 -0
- package/dist/cloudflare/core/tokens/figma-converter.js +323 -0
- package/dist/cloudflare/core/tokens/formatters/css-vars.js +338 -0
- package/dist/cloudflare/core/tokens/formatters/dtcg.js +376 -0
- package/dist/cloudflare/core/tokens/formatters/index.js +45 -0
- package/dist/cloudflare/core/tokens/formatters/json.js +205 -0
- package/dist/cloudflare/core/tokens/formatters/less.js +4 -0
- package/dist/cloudflare/core/tokens/formatters/scss.js +258 -0
- package/dist/cloudflare/core/tokens/formatters/stubs.js +13 -0
- package/dist/cloudflare/core/tokens/formatters/style-dictionary-v3.js +213 -0
- package/dist/cloudflare/core/tokens/formatters/tailwind-v3.js +237 -0
- package/dist/cloudflare/core/tokens/formatters/tailwind-v4.js +335 -0
- package/dist/cloudflare/core/tokens/formatters/tokens-studio.js +256 -0
- package/dist/cloudflare/core/tokens/formatters/ts-module.js +198 -0
- package/dist/cloudflare/core/tokens/index.js +16 -0
- package/dist/cloudflare/core/tokens/parsers/css-vars.js +4 -0
- package/dist/cloudflare/core/tokens/parsers/dtcg.js +280 -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 +20 -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 +152 -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 +1788 -0
- package/dist/cloudflare/core/types/design-code.js +4 -0
- package/dist/cloudflare/core/types/enriched.js +5 -0
- package/dist/cloudflare/core/types/index.js +4 -0
- package/dist/cloudflare/core/variable-resolver.js +85 -0
- package/dist/cloudflare/core/version-tools.js +1199 -0
- package/dist/cloudflare/core/websocket-connector.js +436 -0
- package/dist/cloudflare/core/websocket-server.js +956 -0
- package/dist/cloudflare/core/write-tools.js +2506 -0
- package/dist/cloudflare/index.js +3010 -0
- package/dist/core/accessibility-tools.d.ts +21 -0
- package/dist/core/accessibility-tools.d.ts.map +1 -0
- package/dist/core/accessibility-tools.js +362 -0
- package/dist/core/accessibility-tools.js.map +1 -0
- package/dist/core/annotation-tools.d.ts +14 -0
- package/dist/core/annotation-tools.d.ts.map +1 -0
- package/dist/core/annotation-tools.js +231 -0
- package/dist/core/annotation-tools.js.map +1 -0
- package/dist/core/comment-tools.d.ts +11 -0
- package/dist/core/comment-tools.d.ts.map +1 -0
- package/dist/core/comment-tools.js +293 -0
- package/dist/core/comment-tools.js.map +1 -0
- package/dist/core/config.d.ts +17 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +154 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/console-monitor.d.ts +82 -0
- package/dist/core/console-monitor.d.ts.map +1 -0
- package/dist/core/console-monitor.js +428 -0
- package/dist/core/console-monitor.js.map +1 -0
- package/dist/core/deep-component-tools.d.ts +14 -0
- package/dist/core/deep-component-tools.d.ts.map +1 -0
- package/dist/core/deep-component-tools.js +129 -0
- package/dist/core/deep-component-tools.js.map +1 -0
- package/dist/core/design-code-tools.d.ts +116 -0
- package/dist/core/design-code-tools.d.ts.map +1 -0
- package/dist/core/design-code-tools.js +2794 -0
- package/dist/core/design-code-tools.js.map +1 -0
- package/dist/core/design-system-manifest.d.ts +273 -0
- package/dist/core/design-system-manifest.d.ts.map +1 -0
- package/dist/core/design-system-manifest.js +266 -0
- package/dist/core/design-system-manifest.js.map +1 -0
- package/dist/core/design-system-tools.d.ts +67 -0
- package/dist/core/design-system-tools.d.ts.map +1 -0
- package/dist/core/design-system-tools.js +883 -0
- package/dist/core/design-system-tools.js.map +1 -0
- package/dist/core/diagnose-tool.d.ts +41 -0
- package/dist/core/diagnose-tool.d.ts.map +1 -0
- package/dist/core/diagnose-tool.js +101 -0
- package/dist/core/diagnose-tool.js.map +1 -0
- package/dist/core/diff/changelog-formatter.d.ts +35 -0
- package/dist/core/diff/changelog-formatter.d.ts.map +1 -0
- package/dist/core/diff/changelog-formatter.js +276 -0
- package/dist/core/diff/changelog-formatter.js.map +1 -0
- package/dist/core/diff/diff-engine.d.ts +127 -0
- package/dist/core/diff/diff-engine.d.ts.map +1 -0
- package/dist/core/diff/diff-engine.js +335 -0
- package/dist/core/diff/diff-engine.js.map +1 -0
- package/dist/core/diff/property-compare.d.ts +19 -0
- package/dist/core/diff/property-compare.d.ts.map +1 -0
- package/dist/core/diff/property-compare.js +37 -0
- package/dist/core/diff/property-compare.js.map +1 -0
- package/dist/core/diff/version-cache.d.ts +40 -0
- package/dist/core/diff/version-cache.d.ts.map +1 -0
- package/dist/core/diff/version-cache.js +75 -0
- package/dist/core/diff/version-cache.js.map +1 -0
- package/dist/core/enrichment/enrichment-service.d.ts +52 -0
- package/dist/core/enrichment/enrichment-service.d.ts.map +1 -0
- package/dist/core/enrichment/enrichment-service.js +375 -0
- package/dist/core/enrichment/enrichment-service.js.map +1 -0
- package/dist/core/enrichment/index.d.ts +8 -0
- package/dist/core/enrichment/index.d.ts.map +1 -0
- package/dist/core/enrichment/index.js +8 -0
- package/dist/core/enrichment/index.js.map +1 -0
- package/dist/core/enrichment/relationship-mapper.d.ts +106 -0
- package/dist/core/enrichment/relationship-mapper.d.ts.map +1 -0
- package/dist/core/enrichment/relationship-mapper.js +352 -0
- package/dist/core/enrichment/relationship-mapper.js.map +1 -0
- package/dist/core/enrichment/style-resolver.d.ts +85 -0
- package/dist/core/enrichment/style-resolver.d.ts.map +1 -0
- package/dist/core/enrichment/style-resolver.js +347 -0
- package/dist/core/enrichment/style-resolver.js.map +1 -0
- package/dist/core/figjam-tools.d.ts +8 -0
- package/dist/core/figjam-tools.d.ts.map +1 -0
- package/dist/core/figjam-tools.js +548 -0
- package/dist/core/figjam-tools.js.map +1 -0
- package/dist/core/figma-api.d.ts +254 -0
- package/dist/core/figma-api.d.ts.map +1 -0
- package/dist/core/figma-api.js +510 -0
- package/dist/core/figma-api.js.map +1 -0
- package/dist/core/figma-connector.d.ts +214 -0
- package/dist/core/figma-connector.d.ts.map +1 -0
- package/dist/core/figma-connector.js +8 -0
- package/dist/core/figma-connector.js.map +1 -0
- package/dist/core/figma-desktop-connector.d.ts +312 -0
- package/dist/core/figma-desktop-connector.d.ts.map +1 -0
- package/dist/core/figma-desktop-connector.js +1298 -0
- package/dist/core/figma-desktop-connector.js.map +1 -0
- package/dist/core/figma-reconstruction-spec.d.ts +166 -0
- package/dist/core/figma-reconstruction-spec.d.ts.map +1 -0
- package/dist/core/figma-reconstruction-spec.js +403 -0
- package/dist/core/figma-reconstruction-spec.js.map +1 -0
- package/dist/core/figma-style-extractor.d.ts +76 -0
- package/dist/core/figma-style-extractor.d.ts.map +1 -0
- package/dist/core/figma-style-extractor.js +312 -0
- package/dist/core/figma-style-extractor.js.map +1 -0
- package/dist/core/figma-tools.d.ts +22 -0
- package/dist/core/figma-tools.d.ts.map +1 -0
- package/dist/core/figma-tools.js +3303 -0
- package/dist/core/figma-tools.js.map +1 -0
- 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/library-tools.d.ts +17 -0
- package/dist/core/library-tools.d.ts.map +1 -0
- package/dist/core/library-tools.js +581 -0
- package/dist/core/library-tools.js.map +1 -0
- package/dist/core/logger.d.ts +22 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +54 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/port-discovery.d.ts +211 -0
- package/dist/core/port-discovery.d.ts.map +1 -0
- package/dist/core/port-discovery.js +936 -0
- package/dist/core/port-discovery.js.map +1 -0
- package/dist/core/resolve-package-root.d.ts +2 -0
- package/dist/core/resolve-package-root.d.ts.map +1 -0
- package/dist/core/resolve-package-root.js +12 -0
- package/dist/core/resolve-package-root.js.map +1 -0
- package/dist/core/slides-tools.d.ts +8 -0
- package/dist/core/slides-tools.d.ts.map +1 -0
- package/dist/core/slides-tools.js +715 -0
- package/dist/core/slides-tools.js.map +1 -0
- package/dist/core/slot-tools.d.ts +7 -0
- package/dist/core/slot-tools.d.ts.map +1 -0
- package/dist/core/slot-tools.js +335 -0
- package/dist/core/slot-tools.js.map +1 -0
- package/dist/core/snippet-injector.d.ts +24 -0
- package/dist/core/snippet-injector.d.ts.map +1 -0
- package/dist/core/snippet-injector.js +97 -0
- package/dist/core/snippet-injector.js.map +1 -0
- package/dist/core/tokens/alias-resolver.d.ts +97 -0
- package/dist/core/tokens/alias-resolver.d.ts.map +1 -0
- package/dist/core/tokens/alias-resolver.js +206 -0
- package/dist/core/tokens/alias-resolver.js.map +1 -0
- package/dist/core/tokens/config.d.ts +380 -0
- package/dist/core/tokens/config.d.ts.map +1 -0
- package/dist/core/tokens/config.js +295 -0
- package/dist/core/tokens/config.js.map +1 -0
- package/dist/core/tokens/dialect.d.ts +107 -0
- package/dist/core/tokens/dialect.d.ts.map +1 -0
- package/dist/core/tokens/dialect.js +233 -0
- package/dist/core/tokens/dialect.js.map +1 -0
- package/dist/core/tokens/figma-converter.d.ts +102 -0
- package/dist/core/tokens/figma-converter.d.ts.map +1 -0
- package/dist/core/tokens/figma-converter.js +324 -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 +339 -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 +377 -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 +37 -0
- package/dist/core/tokens/formatters/json.d.ts.map +1 -0
- package/dist/core/tokens/formatters/json.js +206 -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 +26 -0
- package/dist/core/tokens/formatters/scss.d.ts.map +1 -0
- package/dist/core/tokens/formatters/scss.js +259 -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 +14 -0
- package/dist/core/tokens/formatters/stubs.js.map +1 -0
- package/dist/core/tokens/formatters/style-dictionary-v3.d.ts +45 -0
- package/dist/core/tokens/formatters/style-dictionary-v3.d.ts.map +1 -0
- package/dist/core/tokens/formatters/style-dictionary-v3.js +214 -0
- package/dist/core/tokens/formatters/style-dictionary-v3.js.map +1 -0
- package/dist/core/tokens/formatters/tailwind-v3.d.ts +37 -0
- package/dist/core/tokens/formatters/tailwind-v3.d.ts.map +1 -0
- package/dist/core/tokens/formatters/tailwind-v3.js +238 -0
- package/dist/core/tokens/formatters/tailwind-v3.js.map +1 -0
- package/dist/core/tokens/formatters/tailwind-v4.d.ts +41 -0
- package/dist/core/tokens/formatters/tailwind-v4.d.ts.map +1 -0
- package/dist/core/tokens/formatters/tailwind-v4.js +336 -0
- package/dist/core/tokens/formatters/tailwind-v4.js.map +1 -0
- package/dist/core/tokens/formatters/tokens-studio.d.ts +44 -0
- package/dist/core/tokens/formatters/tokens-studio.d.ts.map +1 -0
- package/dist/core/tokens/formatters/tokens-studio.js +257 -0
- package/dist/core/tokens/formatters/tokens-studio.js.map +1 -0
- package/dist/core/tokens/formatters/ts-module.d.ts +35 -0
- package/dist/core/tokens/formatters/ts-module.d.ts.map +1 -0
- package/dist/core/tokens/formatters/ts-module.js +199 -0
- package/dist/core/tokens/formatters/ts-module.js.map +1 -0
- package/dist/core/tokens/index.d.ts +18 -0
- package/dist/core/tokens/index.d.ts.map +1 -0
- package/dist/core/tokens/index.js +17 -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 +281 -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 +15 -0
- package/dist/core/tokens/parsers/stubs.d.ts.map +1 -0
- package/dist/core/tokens/parsers/stubs.js +21 -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 +155 -0
- package/dist/core/tokens/schemas.d.ts.map +1 -0
- package/dist/core/tokens/schemas.js +153 -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 +284 -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 +285 -0
- package/dist/core/tokens-tools.d.ts.map +1 -0
- package/dist/core/tokens-tools.js +1789 -0
- package/dist/core/tokens-tools.js.map +1 -0
- package/dist/core/types/design-code.d.ts +271 -0
- package/dist/core/types/design-code.d.ts.map +1 -0
- package/dist/core/types/design-code.js +5 -0
- package/dist/core/types/design-code.js.map +1 -0
- package/dist/core/types/enriched.d.ts +213 -0
- package/dist/core/types/enriched.d.ts.map +1 -0
- package/dist/core/types/enriched.js +6 -0
- package/dist/core/types/enriched.js.map +1 -0
- package/dist/core/types/index.d.ts +104 -0
- package/dist/core/types/index.d.ts.map +1 -0
- package/dist/core/types/index.js +5 -0
- package/dist/core/types/index.js.map +1 -0
- package/dist/core/variable-resolver.d.ts +45 -0
- package/dist/core/variable-resolver.d.ts.map +1 -0
- package/dist/core/variable-resolver.js +86 -0
- package/dist/core/variable-resolver.js.map +1 -0
- package/dist/core/version-tools.d.ts +59 -0
- package/dist/core/version-tools.d.ts.map +1 -0
- package/dist/core/version-tools.js +1200 -0
- package/dist/core/version-tools.js.map +1 -0
- package/dist/core/websocket-connector.d.ts +247 -0
- package/dist/core/websocket-connector.d.ts.map +1 -0
- package/dist/core/websocket-connector.js +437 -0
- package/dist/core/websocket-connector.js.map +1 -0
- package/dist/core/websocket-server.d.ts +290 -0
- package/dist/core/websocket-server.d.ts.map +1 -0
- package/dist/core/websocket-server.js +957 -0
- package/dist/core/websocket-server.js.map +1 -0
- package/dist/core/write-tools.d.ts +7 -0
- package/dist/core/write-tools.d.ts.map +1 -0
- package/dist/core/write-tools.js +2507 -0
- package/dist/core/write-tools.js.map +1 -0
- package/dist/local.d.ts +105 -0
- package/dist/local.d.ts.map +1 -0
- package/dist/local.js +3314 -0
- package/dist/local.js.map +1 -0
- package/figma-desktop-bridge/README.md +365 -0
- package/figma-desktop-bridge/code.js +7359 -0
- package/figma-desktop-bridge/icon.png +0 -0
- package/figma-desktop-bridge/manifest.json +67 -0
- package/figma-desktop-bridge/ui.html +2783 -0
- package/package.json +103 -0
|
@@ -0,0 +1,1788 @@
|
|
|
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 and a complete apply phase:
|
|
12
|
+
* • toUpdate — value updates batched via the plugin bridge,
|
|
13
|
+
* including alias-target updates ({ type: "VARIABLE_ALIAS", id })
|
|
14
|
+
* when the reference resolves to an existing or just-created
|
|
15
|
+
* variable.
|
|
16
|
+
* • toCreate — missing collections (created with the token file's
|
|
17
|
+
* full mode list) and missing variables in one batched script;
|
|
18
|
+
* literal values first, alias values in a second pass so
|
|
19
|
+
* within-batch alias targets exist. TIMING/EASING cannot be
|
|
20
|
+
* created via the Plugin API and are skipped with a warning.
|
|
21
|
+
* • toDelete — STRICTLY gated behind strategy: "replace". Merge
|
|
22
|
+
* (default) preserves Figma-only variables and only reports them.
|
|
23
|
+
*
|
|
24
|
+
* Both tools auto-discover `tokens.config.json` at the project root and use
|
|
25
|
+
* its source/generated/modes/conflictResolution settings as defaults. They
|
|
26
|
+
* stay zero-arg in normal use.
|
|
27
|
+
*/
|
|
28
|
+
import { writeFileSync, mkdirSync, existsSync, readFileSync, readdirSync, } from "node:fs";
|
|
29
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
30
|
+
import { createChildLogger } from "./logger.js";
|
|
31
|
+
import { buildTokenLookup, canonicalizeTokenValueForComparison, clamp01, convertFigmaVariablesToDocument, ExportTokensInputSchema, ImportTokensInputSchema, format as formatTokenDocument, hexToRawRgba, loadTokensConfig, parse as parseTokenPayload, resolveOutputTargets, stripRawColorFromValues, } from "./tokens/index.js";
|
|
32
|
+
const logger = createChildLogger({ component: "tokens-tools" });
|
|
33
|
+
/**
|
|
34
|
+
* MCP version stamp embedded in DTCG `$extensions["figma-console-mcp"].mcpVersion`
|
|
35
|
+
* on every exported token document. Kept in sync with package.json by
|
|
36
|
+
* scripts/release.sh — see step 3 of the release flow.
|
|
37
|
+
*/
|
|
38
|
+
const MCP_VERSION = "1.35.0";
|
|
39
|
+
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.
|
|
40
|
+
|
|
41
|
+
FULLY-IMPLEMENTED OUTPUT FORMATS:
|
|
42
|
+
• dtcg — W3C DTCG JSON. Canonical pivot format. Round-trip safe via \`$extensions["figma-console-mcp"]\`.
|
|
43
|
+
• css-vars — CSS custom properties with mode-aware selectors (\`:root\`, \`.dark\`, \`[data-theme=...]\`).
|
|
44
|
+
• tailwind-v4 — Tailwind v4 \`@theme inline\` block. Token-to-namespace mapping (color/*, spacing/*, radius/*, etc.) generates Tailwind utility classes.
|
|
45
|
+
• tailwind-v3 — \`tailwind.config.js\` theme.extend object grouped under Tailwind's theme keys (colors, spacing, fontFamily, etc.).
|
|
46
|
+
• scss — \`$var: value;\` declarations. Multi-mode emits a primary variable + a mode-keyed SCSS map for runtime access.
|
|
47
|
+
• ts-module — \`export const tokens = { ... } as const\` with derived \`Tokens\` type. Multi-mode tokens emit as \`{ Light: ..., Dark: ... }\` objects.
|
|
48
|
+
• json-flat — flat key-value JSON (\`{"ds-color-primary": "#4085F2"}\`) for custom build scripts.
|
|
49
|
+
• json-nested — nested object JSON mirroring the token path tree.
|
|
50
|
+
• style-dictionary-v3 — SD v3 source format with bare \`value\`/\`type\` keys (back-compat for existing SD users).
|
|
51
|
+
• tokens-studio — Tokens Studio multi-file layout (\`$themes.json\` + \`$metadata.json\` + per-set files). Preserves Figma collection/mode bindings for round-trip with the TS plugin.
|
|
52
|
+
|
|
53
|
+
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.
|
|
54
|
+
|
|
55
|
+
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).
|
|
56
|
+
|
|
57
|
+
DTCG DIALECT (\`dtcgDialect\`, applies to dtcg/json-flat/json-nested outputs): legacy (default): hex-string colors, maximum compatibility (Style Dictionary v4, Tokens Studio). 2025: DTCG 2025.10 object colors/dimensions (Style Dictionary v5+). figma_import_tokens accepts BOTH dialects regardless of this setting.
|
|
58
|
+
|
|
59
|
+
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. Variable scopes (when non-default) and per-platform codeSyntax (when set) are stashed there too and round-trip through import.`;
|
|
60
|
+
const IMPORT_TOOL_DESCRIPTION = `Push design tokens from your codebase into Figma as variables. Bidirectional with figma_export_tokens.
|
|
61
|
+
|
|
62
|
+
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.
|
|
63
|
+
|
|
64
|
+
APPLY PHASE (full bidirectional sync): toCreate entries create missing collections (with the token file's full mode list) and missing variables in one batched plugin round-trip — literal values first, alias values in a second pass so aliases between newly-created variables resolve. toUpdate entries push value updates in a batched round-trip, INCLUDING alias-target updates: when a token's value is a reference, it's written as a Figma variable alias if the reference resolves to an existing or just-created variable (unresolvable references skip with a warning). toDelete entries are STRICTLY gated behind \`strategy: "replace"\` — in replace mode, Figma variables absent from the token file are permanently deleted (a loud warning lists the count); merge (default) preserves them and only reports. TIMING/EASING variables cannot be created or written via the Plugin API and are skipped with a warning. Variable scopes and per-platform codeSyntax (from \`$extensions["figma-console-mcp"].scopes/.codeSyntax\`) are diffed and applied too — absent fields mean "no opinion" (Figma-side metadata is preserved), an explicit value is authoritative. Partial-success semantics: per-item errors surface in applyResult.errors[] without failing the batch.
|
|
65
|
+
|
|
66
|
+
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.
|
|
67
|
+
|
|
68
|
+
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.
|
|
69
|
+
|
|
70
|
+
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.`;
|
|
71
|
+
export function registerExportTokensTool(server, getDesktopConnector, opts = {}) {
|
|
72
|
+
server.tool("figma_export_tokens", EXPORT_TOOL_DESCRIPTION, ExportTokensInputSchema.shape, async (args) => {
|
|
73
|
+
try {
|
|
74
|
+
return await handleExport(args, getDesktopConnector, opts);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
logger.error({ err }, "figma_export_tokens failed");
|
|
78
|
+
return {
|
|
79
|
+
content: [
|
|
80
|
+
{
|
|
81
|
+
type: "text",
|
|
82
|
+
text: JSON.stringify({
|
|
83
|
+
error: err instanceof Error ? err.message : String(err),
|
|
84
|
+
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.",
|
|
85
|
+
}),
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
isError: true,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
export function registerImportTokensTool(server, getDesktopConnector, opts = {}) {
|
|
94
|
+
server.tool("figma_import_tokens", IMPORT_TOOL_DESCRIPTION, ImportTokensInputSchema._def.schema.shape, async (args) => {
|
|
95
|
+
try {
|
|
96
|
+
return await handleImport(args, getDesktopConnector, opts);
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
logger.error({ err }, "figma_import_tokens failed");
|
|
100
|
+
return {
|
|
101
|
+
content: [
|
|
102
|
+
{
|
|
103
|
+
type: "text",
|
|
104
|
+
text: JSON.stringify({
|
|
105
|
+
error: err instanceof Error ? err.message : String(err),
|
|
106
|
+
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).",
|
|
107
|
+
}),
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
isError: true,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Convenience: register both tools at once.
|
|
117
|
+
*/
|
|
118
|
+
export function registerTokensTools(server, getDesktopConnector, opts = {}) {
|
|
119
|
+
registerExportTokensTool(server, getDesktopConnector, opts);
|
|
120
|
+
registerImportTokensTool(server, getDesktopConnector, opts);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Standardized error for fs-dependent paths called in Cloud Mode.
|
|
124
|
+
*/
|
|
125
|
+
function cloudModeFsError(operation) {
|
|
126
|
+
return new Error(`[figma-console-mcp] ${operation} is a Local Mode operation — Cloud Mode (Cloudflare Workers) has no local filesystem access. ` +
|
|
127
|
+
"Use one of these alternatives:\n" +
|
|
128
|
+
" • 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" +
|
|
129
|
+
" • Import: pass token data inline via the `payload` argument (single file) or `files` argument (multi-file). Omit `configPath`.\n" +
|
|
130
|
+
"For full filesystem support (tokens.config.json autodiscovery, automatic writes to source/generated dirs), run the MCP in Local Mode via NPX.");
|
|
131
|
+
}
|
|
132
|
+
// ============================================================================
|
|
133
|
+
// HANDLERS
|
|
134
|
+
// ============================================================================
|
|
135
|
+
async function handleExport(args, getDesktopConnector, opts) {
|
|
136
|
+
// Cloud Mode guard. Any filesystem operation needs to bail with a clear
|
|
137
|
+
// message before the fs call actually throws something cryptic.
|
|
138
|
+
if (opts.isRemoteMode) {
|
|
139
|
+
if (args.configPath) {
|
|
140
|
+
throw cloudModeFsError("`configPath` (tokens.config.json autodiscovery)");
|
|
141
|
+
}
|
|
142
|
+
if (args.outputPath) {
|
|
143
|
+
throw cloudModeFsError("`outputPath` (writing token files to disk)");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// 1. Load config (autodiscover or explicit). Skip entirely in Cloud Mode
|
|
147
|
+
// — tokens.config.json lookup requires a filesystem.
|
|
148
|
+
const loaded = opts.isRemoteMode
|
|
149
|
+
? null
|
|
150
|
+
: loadTokensConfig({ explicitPath: args.configPath });
|
|
151
|
+
// 2. Fetch variables from Figma via the desktop connector. The connector's
|
|
152
|
+
// getVariablesFromPluginUI returns the plugin's cached variable data
|
|
153
|
+
// (instant, all plans, full Plugin API fidelity).
|
|
154
|
+
const connector = await getDesktopConnector();
|
|
155
|
+
// We don't have a specific fileKey here unless the caller passed one in
|
|
156
|
+
// config; pass undefined to let the connector use the currently-connected
|
|
157
|
+
// file context.
|
|
158
|
+
const fileKey = loaded?.config?.figmaFile
|
|
159
|
+
? extractFileKey(loaded.config.figmaFile)
|
|
160
|
+
: undefined;
|
|
161
|
+
const raw = await connector.getVariablesFromPluginUI(fileKey);
|
|
162
|
+
// Unwrap the plugin's response — same logic as the existing figma_get_variables.
|
|
163
|
+
const variableData = raw?.result?.variables ? raw.result : raw;
|
|
164
|
+
if (!variableData?.variables) {
|
|
165
|
+
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.");
|
|
166
|
+
}
|
|
167
|
+
// 3. Normalize to the converter's expected shape.
|
|
168
|
+
const payload = normalizeFigmaPayload(variableData);
|
|
169
|
+
// 4. Convert to canonical TokenDocument.
|
|
170
|
+
const { document, warnings } = convertFigmaVariablesToDocument(payload, {
|
|
171
|
+
figmaFileKey: fileKey,
|
|
172
|
+
collectionIds: args.collectionIds,
|
|
173
|
+
modes: args.modes,
|
|
174
|
+
stripPrefix: args.prefix,
|
|
175
|
+
mcpVersion: MCP_VERSION,
|
|
176
|
+
});
|
|
177
|
+
// 5. Resolve which output formats to emit.
|
|
178
|
+
const targets = resolveOutputTargets(loaded?.config ?? null, args.format);
|
|
179
|
+
// 6. Format the document for each target.
|
|
180
|
+
const allFiles = [];
|
|
181
|
+
const allWarnings = [...warnings];
|
|
182
|
+
for (const target of targets) {
|
|
183
|
+
try {
|
|
184
|
+
const result = formatTokenDocument(document, {
|
|
185
|
+
target: {
|
|
186
|
+
...target,
|
|
187
|
+
splitByMode: args.splitByMode ?? target.splitByMode,
|
|
188
|
+
splitByCollection: args.splitByCollection ?? target.splitByCollection,
|
|
189
|
+
prefix: args.prefix ?? target.prefix,
|
|
190
|
+
resolveAliases: args.resolveAliases ?? target.resolveAliases,
|
|
191
|
+
dtcgDialect: args.dtcgDialect ?? target.dtcgDialect,
|
|
192
|
+
transforms: {
|
|
193
|
+
colorFormat: args.colorFormat ?? target.transforms?.colorFormat,
|
|
194
|
+
sizeUnit: args.sizeUnit ?? target.transforms?.sizeUnit,
|
|
195
|
+
remBase: args.remBase ?? target.transforms?.remBase,
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
projectRoot: loaded?.projectRoot,
|
|
199
|
+
});
|
|
200
|
+
for (const file of result.files) {
|
|
201
|
+
allFiles.push({ format: target.format, ...file });
|
|
202
|
+
}
|
|
203
|
+
allWarnings.push(...result.warnings);
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
// Non-DTCG formatters throw NotImplementedError. Surface it without
|
|
207
|
+
// bailing on the other targets.
|
|
208
|
+
allWarnings.push(`[${target.format}] ${err instanceof Error ? err.message : String(err)}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// 7. If outputPath is set, write to disk. Otherwise return inline.
|
|
212
|
+
// Output routing: canonical-format files (matching config.source.canonical)
|
|
213
|
+
// go to source.dir; everything else goes to generated.dir.
|
|
214
|
+
const dryRun = args.strategy === "dry-run";
|
|
215
|
+
const writtenPaths = [];
|
|
216
|
+
if (!dryRun) {
|
|
217
|
+
for (const file of allFiles) {
|
|
218
|
+
const base = resolveOutputBaseForFormat(args.outputPath, loaded, file.format);
|
|
219
|
+
if (!base)
|
|
220
|
+
continue; // No config or outputPath → caller will get content inline.
|
|
221
|
+
const fullPath = isAbsolute(file.path)
|
|
222
|
+
? file.path
|
|
223
|
+
: join(base, file.path);
|
|
224
|
+
mkdirSync(dirname(fullPath), { recursive: true });
|
|
225
|
+
writeFileSync(fullPath, file.content, "utf-8");
|
|
226
|
+
writtenPaths.push(fullPath);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const outputBase = writtenPaths.length > 0 ? "(multiple)" : null;
|
|
230
|
+
return {
|
|
231
|
+
content: [
|
|
232
|
+
{
|
|
233
|
+
type: "text",
|
|
234
|
+
text: JSON.stringify({
|
|
235
|
+
success: true,
|
|
236
|
+
mode: dryRun ? "dry-run" : outputBase ? "written" : "inline",
|
|
237
|
+
configFound: !!loaded,
|
|
238
|
+
configPath: loaded?.configPath ?? null,
|
|
239
|
+
collections: document.sets.map((s) => ({
|
|
240
|
+
name: s.name,
|
|
241
|
+
modes: s.modes,
|
|
242
|
+
tokenCount: s.tokens.length,
|
|
243
|
+
figmaCollectionId: s.meta?.figmaCollectionId,
|
|
244
|
+
})),
|
|
245
|
+
outputs: dryRun
|
|
246
|
+
? allFiles.map((f) => ({
|
|
247
|
+
format: f.format,
|
|
248
|
+
path: f.path,
|
|
249
|
+
preview: f.content.slice(0, 500) + (f.content.length > 500 ? "…" : ""),
|
|
250
|
+
}))
|
|
251
|
+
: outputBase
|
|
252
|
+
? writtenPaths.map((p) => ({ writtenTo: p }))
|
|
253
|
+
: allFiles,
|
|
254
|
+
warnings: allWarnings,
|
|
255
|
+
...(loaded
|
|
256
|
+
? {}
|
|
257
|
+
: {
|
|
258
|
+
suggestedScaffold: {
|
|
259
|
+
note: "No tokens.config.json detected. Recommended scaffold:",
|
|
260
|
+
configContent: JSON.stringify({
|
|
261
|
+
$schema: "https://figma-console-mcp.southleft.com/schemas/tokens.config.v1.json",
|
|
262
|
+
source: { dir: "src/styles/tokens", canonical: "dtcg" },
|
|
263
|
+
generated: {
|
|
264
|
+
dir: "src/styles/generated",
|
|
265
|
+
formats: [{ format: "css-vars", splitByMode: true }],
|
|
266
|
+
},
|
|
267
|
+
conflictResolution: "ask",
|
|
268
|
+
}, null, 2),
|
|
269
|
+
directories: ["src/styles/tokens", "src/styles/generated"],
|
|
270
|
+
nextSteps: "Write tokens.config.json at the project root, create the directories, then call figma_export_tokens again — zero args needed.",
|
|
271
|
+
},
|
|
272
|
+
}),
|
|
273
|
+
}, null, 2),
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
async function handleImport(args, getDesktopConnector, opts) {
|
|
279
|
+
// Cloud Mode guard: filesystem operations are unavailable. Inline
|
|
280
|
+
// `payload` / `files` arguments still work and are the supported path.
|
|
281
|
+
if (opts.isRemoteMode) {
|
|
282
|
+
if (args.configPath) {
|
|
283
|
+
throw cloudModeFsError("`configPath` (tokens.config.json autodiscovery)");
|
|
284
|
+
}
|
|
285
|
+
if (!args.payload && !args.files) {
|
|
286
|
+
throw cloudModeFsError("Implicit source-dir reads (when neither `payload` nor `files` is provided)");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// 1. Load config + resolve where the source payload(s) live.
|
|
290
|
+
const loaded = opts.isRemoteMode
|
|
291
|
+
? null
|
|
292
|
+
: loadTokensConfig({ explicitPath: args.configPath });
|
|
293
|
+
// 2. Collect input payloads.
|
|
294
|
+
const inputFiles = collectInputFiles(args, loaded);
|
|
295
|
+
// 3. Parse each input file to a TokenDocument.
|
|
296
|
+
const documents = [];
|
|
297
|
+
const parseWarnings = [];
|
|
298
|
+
for (const file of inputFiles) {
|
|
299
|
+
const parseResult = parseTokenPayload(args.format ?? "auto", {
|
|
300
|
+
payload: file.content,
|
|
301
|
+
sourcePath: file.path,
|
|
302
|
+
});
|
|
303
|
+
documents.push(parseResult.document);
|
|
304
|
+
parseWarnings.push(...parseResult.warnings);
|
|
305
|
+
}
|
|
306
|
+
// 4. Merge documents into a single TokenDocument (sets are concatenated;
|
|
307
|
+
// tokens within sets are combined by path).
|
|
308
|
+
const merged = mergeDocuments(documents);
|
|
309
|
+
// 5. Fetch current Figma state for diffing.
|
|
310
|
+
const connector = await getDesktopConnector();
|
|
311
|
+
const fileKey = loaded?.config?.figmaFile
|
|
312
|
+
? extractFileKey(loaded.config.figmaFile)
|
|
313
|
+
: undefined;
|
|
314
|
+
const raw = await connector.getVariablesFromPluginUI(fileKey);
|
|
315
|
+
const variableData = raw?.result?.variables ? raw.result : raw;
|
|
316
|
+
const figmaPayload = normalizeFigmaPayload(variableData ?? { variables: [], variableCollections: [] });
|
|
317
|
+
const { document: figmaDoc } = convertFigmaVariablesToDocument(figmaPayload, {
|
|
318
|
+
figmaFileKey: fileKey,
|
|
319
|
+
mcpVersion: MCP_VERSION,
|
|
320
|
+
});
|
|
321
|
+
// 6. Compute the diff plan.
|
|
322
|
+
const diff = computeDiffPlan(figmaDoc, merged);
|
|
323
|
+
const dryRun = args.dryRun === true || args.strategy === "dry-run";
|
|
324
|
+
const strategy = args.strategy === "replace" ? "replace" : "merge";
|
|
325
|
+
// 7. Apply phase: when not dry-run, mutate Figma via the plugin bridge in
|
|
326
|
+
// three ordered sub-phases. Order matters: creates run FIRST so that
|
|
327
|
+
// alias-target updates in the update phase can point at just-created
|
|
328
|
+
// variables; deletes run LAST (and only under strategy "replace").
|
|
329
|
+
let applyResult = null;
|
|
330
|
+
let deleteWarning;
|
|
331
|
+
if (!dryRun) {
|
|
332
|
+
const acc = {
|
|
333
|
+
applied: 0,
|
|
334
|
+
created: 0,
|
|
335
|
+
createdCollections: 0,
|
|
336
|
+
renamed: 0,
|
|
337
|
+
deleted: 0,
|
|
338
|
+
failed: 0,
|
|
339
|
+
errors: [],
|
|
340
|
+
};
|
|
341
|
+
let anyPhaseRan = false;
|
|
342
|
+
const collectionModeMap = buildCollectionModeMap(figmaPayload);
|
|
343
|
+
const toCreateKeys = new Set(diff.toCreate.map((e) => e.path));
|
|
344
|
+
// Mutable map the alias resolver closes over — populated by the create
|
|
345
|
+
// phase so later alias resolutions see freshly-created variable IDs.
|
|
346
|
+
const createdIdByKey = new Map();
|
|
347
|
+
const resolveAliasId = makeAliasIdResolver(figmaDoc, merged, toCreateKeys, createdIdByKey);
|
|
348
|
+
// 7a. CREATE phase — missing collections (with the token file's full
|
|
349
|
+
// mode list) and missing variables. Literal values are set at
|
|
350
|
+
// creation; alias values apply in a second in-script pass after ALL
|
|
351
|
+
// variables exist, so aliases among created variables resolve.
|
|
352
|
+
if (diff.toCreate.length > 0) {
|
|
353
|
+
const createPlan = buildCreatePlan(diff.toCreate, merged, figmaPayload, resolveAliasId, parseWarnings);
|
|
354
|
+
if (createPlan.newCollections.length > 0 ||
|
|
355
|
+
createPlan.existingCollections.length > 0) {
|
|
356
|
+
anyPhaseRan = true;
|
|
357
|
+
const createResult = await applyCreates(connector, createPlan);
|
|
358
|
+
acc.created += createResult.created;
|
|
359
|
+
acc.createdCollections += createResult.createdCollections;
|
|
360
|
+
acc.failed += createResult.failed;
|
|
361
|
+
acc.errors.push(...createResult.errors);
|
|
362
|
+
for (const [key, id] of createResult.createdIdByKey) {
|
|
363
|
+
createdIdByKey.set(key, id);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// 7b. UPDATE phase — value updates including alias-target updates
|
|
368
|
+
// (references resolved to { type: "VARIABLE_ALIAS", id }) AND
|
|
369
|
+
// renames (ID-matched tokens whose path moved: variable.name is set
|
|
370
|
+
// to the new '/'-joined path instead of create+delete).
|
|
371
|
+
if (diff.toUpdate.length > 0 || diff.toRename.length > 0) {
|
|
372
|
+
const renameEntries = diff.toRename.map((r) => ({
|
|
373
|
+
path: r.from, // Figma-side lookup (old key)
|
|
374
|
+
codePath: r.path, // code-side lookup (new key)
|
|
375
|
+
newName: r.newName,
|
|
376
|
+
before: undefined,
|
|
377
|
+
after: undefined,
|
|
378
|
+
changes: r.changes,
|
|
379
|
+
}));
|
|
380
|
+
const updates = buildUpdatePayloads([...renameEntries, ...diff.toUpdate], figmaDoc, merged, collectionModeMap, parseWarnings, resolveAliasId);
|
|
381
|
+
if (updates.length > 0) {
|
|
382
|
+
anyPhaseRan = true;
|
|
383
|
+
const renameIds = new Set(updates.filter((u) => u.newName !== undefined).map((u) => u.variableId));
|
|
384
|
+
const updateResult = await applyUpdates(connector, updates);
|
|
385
|
+
const renamedFailed = updateResult.errors.filter((e) => renameIds.has(e.variableId)).length;
|
|
386
|
+
const renamedOk = renameIds.size - renamedFailed;
|
|
387
|
+
acc.renamed += renamedOk;
|
|
388
|
+
// `applied` counts successful non-rename update entries; a rename
|
|
389
|
+
// entry (even one that also carried value changes) counts once,
|
|
390
|
+
// under `renamed`.
|
|
391
|
+
acc.applied += updateResult.applied - renamedOk;
|
|
392
|
+
acc.failed += updateResult.failed;
|
|
393
|
+
acc.errors.push(...updateResult.errors);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// 7c. DELETE phase — STRICTLY gated behind strategy "replace". Merge
|
|
397
|
+
// (the default) never deletes; it only reports Figma-only tokens.
|
|
398
|
+
if (strategy === "replace" && diff.toDelete.length > 0) {
|
|
399
|
+
anyPhaseRan = true;
|
|
400
|
+
const deleteResult = await applyDeletes(connector, diff.toDelete, figmaDoc);
|
|
401
|
+
acc.deleted += deleteResult.deleted;
|
|
402
|
+
acc.failed += deleteResult.failed;
|
|
403
|
+
acc.errors.push(...deleteResult.errors);
|
|
404
|
+
if (deleteResult.deleted > 0) {
|
|
405
|
+
deleteWarning = `⚠️ REPLACE STRATEGY: permanently deleted ${deleteResult.deleted} Figma variable(s) that were not present in the token file. Recover via Figma's version history / Edit > Undo if this was unintended.`;
|
|
406
|
+
parseWarnings.push(deleteWarning);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (anyPhaseRan)
|
|
410
|
+
applyResult = acc;
|
|
411
|
+
}
|
|
412
|
+
// Slim the diff for the response: full entries blow past LLM context for
|
|
413
|
+
// large design systems. Show counts + a sample of first N entries from
|
|
414
|
+
// each bucket; the caller can re-run with format=detailed if they want
|
|
415
|
+
// everything.
|
|
416
|
+
const SAMPLE_LIMIT = 20;
|
|
417
|
+
const slimDiff = {
|
|
418
|
+
summary: {
|
|
419
|
+
toCreate: diff.toCreate.length,
|
|
420
|
+
toUpdate: diff.toUpdate.length,
|
|
421
|
+
toRename: diff.toRename.length,
|
|
422
|
+
toDelete: diff.toDelete.length,
|
|
423
|
+
unchanged: diff.unchanged,
|
|
424
|
+
},
|
|
425
|
+
samples: {
|
|
426
|
+
toCreate: diff.toCreate.slice(0, SAMPLE_LIMIT).map((e) => ({
|
|
427
|
+
path: e.path,
|
|
428
|
+
type: e.type,
|
|
429
|
+
})),
|
|
430
|
+
toUpdate: diff.toUpdate.slice(0, SAMPLE_LIMIT),
|
|
431
|
+
toRename: diff.toRename.slice(0, SAMPLE_LIMIT).map((e) => ({
|
|
432
|
+
from: e.from,
|
|
433
|
+
to: e.path,
|
|
434
|
+
variableId: e.variableId,
|
|
435
|
+
})),
|
|
436
|
+
toDelete: diff.toDelete.slice(0, SAMPLE_LIMIT),
|
|
437
|
+
},
|
|
438
|
+
truncated: {
|
|
439
|
+
toCreate: diff.toCreate.length > SAMPLE_LIMIT,
|
|
440
|
+
toUpdate: diff.toUpdate.length > SAMPLE_LIMIT,
|
|
441
|
+
toRename: diff.toRename.length > SAMPLE_LIMIT,
|
|
442
|
+
toDelete: diff.toDelete.length > SAMPLE_LIMIT,
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
return {
|
|
446
|
+
content: [
|
|
447
|
+
{
|
|
448
|
+
type: "text",
|
|
449
|
+
text: JSON.stringify({
|
|
450
|
+
success: true,
|
|
451
|
+
mode: dryRun ? "dry-run" : applyResult ? "applied" : "no-changes",
|
|
452
|
+
applyNote: dryRun
|
|
453
|
+
? "Dry-run only — no Figma mutations performed."
|
|
454
|
+
: applyResult
|
|
455
|
+
? [
|
|
456
|
+
applyResult.createdCollections > 0
|
|
457
|
+
? `Created ${applyResult.createdCollections} collection(s).`
|
|
458
|
+
: null,
|
|
459
|
+
applyResult.created > 0
|
|
460
|
+
? `Created ${applyResult.created} variable(s).`
|
|
461
|
+
: null,
|
|
462
|
+
applyResult.renamed > 0
|
|
463
|
+
? `Renamed ${applyResult.renamed} variable(s).`
|
|
464
|
+
: null,
|
|
465
|
+
`Applied ${applyResult.applied} value update(s).`,
|
|
466
|
+
applyResult.deleted > 0
|
|
467
|
+
? `Deleted ${applyResult.deleted} variable(s) (replace strategy).`
|
|
468
|
+
: null,
|
|
469
|
+
`${applyResult.failed} failed.`,
|
|
470
|
+
]
|
|
471
|
+
.filter(Boolean)
|
|
472
|
+
.join(" ")
|
|
473
|
+
: diff.toUpdate.length === 0 &&
|
|
474
|
+
diff.toCreate.length === 0 &&
|
|
475
|
+
diff.toRename.length === 0
|
|
476
|
+
? "Nothing to apply — all tokens already in sync."
|
|
477
|
+
: "Changes were detected but skipped (likely all unresolvable aliases or non-writable TIMING/EASING types — see warnings).",
|
|
478
|
+
deleteNote: deleteWarning
|
|
479
|
+
? deleteWarning
|
|
480
|
+
: diff.toDelete.length > 0
|
|
481
|
+
? strategy === "replace" && dryRun
|
|
482
|
+
? `${diff.toDelete.length} Figma-only token(s) would be PERMANENTLY DELETED on apply (replace strategy + dry-run).`
|
|
483
|
+
: strategy === "replace"
|
|
484
|
+
? `${diff.toDelete.length} Figma-only token(s) targeted for deletion (replace strategy) — see applyResult for outcome.`
|
|
485
|
+
: `${diff.toDelete.length} Figma-only token(s) preserved (merge strategy). Use strategy: "replace" to delete them, or figma_delete_variable manually.`
|
|
486
|
+
: undefined,
|
|
487
|
+
inputFileCount: inputFiles.length,
|
|
488
|
+
parsedSetCount: merged.sets.length,
|
|
489
|
+
parsedTokenCount: merged.sets.reduce((n, s) => n + s.tokens.length, 0),
|
|
490
|
+
diff: slimDiff,
|
|
491
|
+
applyResult: applyResult
|
|
492
|
+
? {
|
|
493
|
+
applied: applyResult.applied,
|
|
494
|
+
created: applyResult.created,
|
|
495
|
+
createdCollections: applyResult.createdCollections,
|
|
496
|
+
renamed: applyResult.renamed,
|
|
497
|
+
deleted: applyResult.deleted,
|
|
498
|
+
failed: applyResult.failed,
|
|
499
|
+
errors: applyResult.errors.slice(0, 10),
|
|
500
|
+
}
|
|
501
|
+
: null,
|
|
502
|
+
warnings: parseWarnings,
|
|
503
|
+
}, null, 2),
|
|
504
|
+
},
|
|
505
|
+
],
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
// ============================================================================
|
|
509
|
+
// HELPERS
|
|
510
|
+
// ============================================================================
|
|
511
|
+
/**
|
|
512
|
+
* Extract a Figma file key from a URL or return the string as-is if it's
|
|
513
|
+
* already a key.
|
|
514
|
+
*/
|
|
515
|
+
function extractFileKey(figmaFileOrUrl) {
|
|
516
|
+
const match = figmaFileOrUrl.match(/figma\.com\/(?:file|design)\/([a-zA-Z0-9]+)/);
|
|
517
|
+
return match ? match[1] : figmaFileOrUrl;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Normalize the plugin's variable response into the converter's expected
|
|
521
|
+
* shape. The plugin may return collections keyed by ID or as an array;
|
|
522
|
+
* normalize both into an array.
|
|
523
|
+
*/
|
|
524
|
+
function normalizeFigmaPayload(raw) {
|
|
525
|
+
const collections = Array.isArray(raw.variableCollections)
|
|
526
|
+
? raw.variableCollections
|
|
527
|
+
: Object.entries(raw.variableCollections ?? {}).map(([id, c]) => ({ id, ...c }));
|
|
528
|
+
const variables = Array.isArray(raw.variables)
|
|
529
|
+
? raw.variables
|
|
530
|
+
: Object.entries(raw.variables ?? {}).map(([id, v]) => ({
|
|
531
|
+
id,
|
|
532
|
+
...v,
|
|
533
|
+
}));
|
|
534
|
+
return { collections, variables };
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Resolve where to write output files for a specific format. Canonical formats
|
|
538
|
+
* (matching config.source.canonical) go to source.dir; everything else goes
|
|
539
|
+
* to generated.dir. Caller-supplied outputPath wins over both.
|
|
540
|
+
*/
|
|
541
|
+
function resolveOutputBaseForFormat(outputPath, loaded, format) {
|
|
542
|
+
// Explicit outputPath always wins.
|
|
543
|
+
if (outputPath) {
|
|
544
|
+
return isAbsolute(outputPath)
|
|
545
|
+
? outputPath
|
|
546
|
+
: resolve(loaded?.projectRoot ?? process.cwd(), outputPath);
|
|
547
|
+
}
|
|
548
|
+
if (!loaded)
|
|
549
|
+
return null;
|
|
550
|
+
// Canonical format goes to source.dir.
|
|
551
|
+
if (format === loaded.config.source.canonical) {
|
|
552
|
+
return resolve(loaded.projectRoot, loaded.config.source.dir);
|
|
553
|
+
}
|
|
554
|
+
// Otherwise generated.dir.
|
|
555
|
+
if (loaded.config.generated?.dir) {
|
|
556
|
+
return resolve(loaded.projectRoot, loaded.config.generated.dir);
|
|
557
|
+
}
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Gather input files for import. Priority:
|
|
562
|
+
* 1. Explicit payload string.
|
|
563
|
+
* 2. Explicit files array.
|
|
564
|
+
* 3. Config-derived source dir (read every *.tokens.json file).
|
|
565
|
+
*/
|
|
566
|
+
function collectInputFiles(args, loaded) {
|
|
567
|
+
if (args.payload) {
|
|
568
|
+
return [{ path: "<inline>", content: args.payload }];
|
|
569
|
+
}
|
|
570
|
+
if (args.files?.length) {
|
|
571
|
+
return args.files;
|
|
572
|
+
}
|
|
573
|
+
if (!loaded) {
|
|
574
|
+
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.");
|
|
575
|
+
}
|
|
576
|
+
// Walk the source dir for *.tokens.json files. Currently a flat scan;
|
|
577
|
+
// the config's source.pattern is honored as a simple glob (just suffix
|
|
578
|
+
// matching for now).
|
|
579
|
+
const sourceDir = resolve(loaded.projectRoot, loaded.config.source.dir);
|
|
580
|
+
if (!existsSync(sourceDir)) {
|
|
581
|
+
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.`);
|
|
582
|
+
}
|
|
583
|
+
const pattern = loaded.config.source.pattern ?? "*.tokens.json";
|
|
584
|
+
const suffix = pattern.replace(/^\*/, "");
|
|
585
|
+
const entries = readdirSync(sourceDir);
|
|
586
|
+
return entries
|
|
587
|
+
.filter((e) => e.endsWith(suffix))
|
|
588
|
+
.map((name) => {
|
|
589
|
+
const full = join(sourceDir, name);
|
|
590
|
+
return { path: full, content: readFileSync(full, "utf-8") };
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Merge multiple TokenDocuments. Sets with the same name combine their
|
|
595
|
+
* tokens; tokens with the same path within a set have their mode-values
|
|
596
|
+
* merged (so splitByMode files reassemble cleanly into one multi-mode
|
|
597
|
+
* representation). Modes are unioned. Document-level metadata uses the
|
|
598
|
+
* first document's values.
|
|
599
|
+
*/
|
|
600
|
+
function mergeDocuments(docs) {
|
|
601
|
+
if (docs.length === 0) {
|
|
602
|
+
return { sets: [], meta: {} };
|
|
603
|
+
}
|
|
604
|
+
if (docs.length === 1)
|
|
605
|
+
return docs[0];
|
|
606
|
+
const setsByName = new Map();
|
|
607
|
+
for (const doc of docs) {
|
|
608
|
+
for (const set of doc.sets) {
|
|
609
|
+
const existing = setsByName.get(set.name);
|
|
610
|
+
if (!existing) {
|
|
611
|
+
setsByName.set(set.name, { ...set, tokens: [...set.tokens] });
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
existing.modes = [...new Set([...existing.modes, ...set.modes])];
|
|
615
|
+
// Dedupe by path: tokens with the same path merge their values.
|
|
616
|
+
// Critical for splitByMode output where each file has the same tokens
|
|
617
|
+
// with a different mode's value, and the import needs to reassemble
|
|
618
|
+
// them into one multi-mode token instead of triplicating.
|
|
619
|
+
const byPath = new Map(existing.tokens.map((t) => [t.path.join("/"), t]));
|
|
620
|
+
for (const incoming of set.tokens) {
|
|
621
|
+
const key = incoming.path.join("/");
|
|
622
|
+
const found = byPath.get(key);
|
|
623
|
+
if (found) {
|
|
624
|
+
found.values = { ...found.values, ...incoming.values };
|
|
625
|
+
// Merge MCP extensions too — newer lastSyncedAt wins, lastSyncedValue
|
|
626
|
+
// unions across modes.
|
|
627
|
+
const aExt = found.extensions?.["figma-console-mcp"];
|
|
628
|
+
const bExt = incoming.extensions?.["figma-console-mcp"];
|
|
629
|
+
if (aExt || bExt) {
|
|
630
|
+
const merged = { ...(aExt ?? {}), ...(bExt ?? {}) };
|
|
631
|
+
if (aExt?.lastSyncedValue || bExt?.lastSyncedValue) {
|
|
632
|
+
merged.lastSyncedValue = {
|
|
633
|
+
...(aExt?.lastSyncedValue ?? {}),
|
|
634
|
+
...(bExt?.lastSyncedValue ?? {}),
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
found.extensions = { ...(found.extensions ?? {}), "figma-console-mcp": merged };
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
existing.tokens.push(incoming);
|
|
642
|
+
byPath.set(key, incoming);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
$schema: docs[0].$schema,
|
|
649
|
+
sets: [...setsByName.values()],
|
|
650
|
+
meta: docs[0].meta,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Compute a diff plan between Figma's current state (left) and the code's
|
|
655
|
+
* proposed state (right). Returns a structured diff plan; value-update
|
|
656
|
+
* mutations are applied via the plugin bridge below.
|
|
657
|
+
*
|
|
658
|
+
* MATCH PRIORITY (as the tool description promises): Figma variable ID
|
|
659
|
+
* first, then exact set::path. A code-side token whose path has no Figma
|
|
660
|
+
* counterpart but whose $extensions["figma-console-mcp"].variableId matches
|
|
661
|
+
* a live variable is a RENAME — routed to toRename (name change + any
|
|
662
|
+
* value/meta changes), and its Figma-side counterpart is EXCLUDED from
|
|
663
|
+
* toDelete. Without this, a path rename in the code file would create a
|
|
664
|
+
* duplicate variable under merge, and under replace would permanently
|
|
665
|
+
* delete the original (detaching all its bindings).
|
|
666
|
+
*
|
|
667
|
+
* Exported for test coverage of the dialect-normalized comparison
|
|
668
|
+
* (round-trip exports in both DTCG dialects must diff as unchanged) and
|
|
669
|
+
* rename classification.
|
|
670
|
+
*/
|
|
671
|
+
export function computeDiffPlan(figmaDoc, codeDoc) {
|
|
672
|
+
// Build lookup maps by path for both sides.
|
|
673
|
+
const figmaTokens = new Map();
|
|
674
|
+
for (const set of figmaDoc.sets) {
|
|
675
|
+
for (const t of set.tokens) {
|
|
676
|
+
figmaTokens.set(`${set.name}::${t.path.join(".")}`, t);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
const codeTokens = new Map();
|
|
680
|
+
for (const set of codeDoc.sets) {
|
|
681
|
+
for (const t of set.tokens) {
|
|
682
|
+
codeTokens.set(`${set.name}::${t.path.join(".")}`, t);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
// ID-first index: live Figma variableId → its diff key + token.
|
|
686
|
+
const figmaByVariableId = new Map();
|
|
687
|
+
for (const [key, token] of figmaTokens) {
|
|
688
|
+
const id = token.extensions?.["figma-console-mcp"]?.variableId;
|
|
689
|
+
if (typeof id === "string")
|
|
690
|
+
figmaByVariableId.set(id, { key, token });
|
|
691
|
+
}
|
|
692
|
+
const toCreate = [];
|
|
693
|
+
const toUpdate = [];
|
|
694
|
+
const toRename = [];
|
|
695
|
+
// Figma-side keys consumed by a rename — excluded from toDelete.
|
|
696
|
+
const renamedFigmaKeys = new Set();
|
|
697
|
+
const toDelete = [];
|
|
698
|
+
let unchanged = 0;
|
|
699
|
+
for (const [key, codeToken] of codeTokens) {
|
|
700
|
+
const figmaToken = figmaTokens.get(key);
|
|
701
|
+
if (!figmaToken) {
|
|
702
|
+
// No path match — try the ID-first match before classifying as a
|
|
703
|
+
// create. Guards: the matched Figma path must not ALSO exist in the
|
|
704
|
+
// code doc (then the ID was copy-pasted, not renamed), and each Figma
|
|
705
|
+
// variable can be claimed by at most one rename.
|
|
706
|
+
const extId = codeToken.extensions?.["figma-console-mcp"]?.variableId;
|
|
707
|
+
const idMatch = typeof extId === "string" ? figmaByVariableId.get(extId) : undefined;
|
|
708
|
+
if (idMatch &&
|
|
709
|
+
!codeTokens.has(idMatch.key) &&
|
|
710
|
+
!renamedFigmaKeys.has(idMatch.key)) {
|
|
711
|
+
renamedFigmaKeys.add(idMatch.key);
|
|
712
|
+
const valuesChanged = !valuesEqual(idMatch.token.values, codeToken.values);
|
|
713
|
+
const meta = diffVariableMeta(idMatch.token, codeToken);
|
|
714
|
+
toRename.push({
|
|
715
|
+
path: key,
|
|
716
|
+
from: idMatch.key,
|
|
717
|
+
variableId: extId,
|
|
718
|
+
newName: codeToken.path.join("/"),
|
|
719
|
+
changes: {
|
|
720
|
+
values: valuesChanged,
|
|
721
|
+
scopes: meta.scopesChanged,
|
|
722
|
+
codeSyntax: meta.codeSyntaxChanged,
|
|
723
|
+
},
|
|
724
|
+
});
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
toCreate.push({
|
|
728
|
+
path: key,
|
|
729
|
+
type: codeToken.type,
|
|
730
|
+
value: codeToken.values,
|
|
731
|
+
});
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
const valuesChanged = !valuesEqual(figmaToken.values, codeToken.values);
|
|
735
|
+
const meta = diffVariableMeta(figmaToken, codeToken);
|
|
736
|
+
if (valuesChanged || meta.scopesChanged || meta.codeSyntaxChanged) {
|
|
737
|
+
if (valuesChanged && !meta.scopesChanged && !meta.codeSyntaxChanged) {
|
|
738
|
+
// Value-only update — historical entry shape, no `changes` field, so
|
|
739
|
+
// pre-existing consumers/tests see exactly what they always saw.
|
|
740
|
+
toUpdate.push({
|
|
741
|
+
path: key,
|
|
742
|
+
// Figma-side values carry the transient rawColor floats — strip
|
|
743
|
+
// them so diff samples in the tool response stay shaped as before.
|
|
744
|
+
before: stripRawColorFromValues(figmaToken.values),
|
|
745
|
+
after: codeToken.values,
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
else {
|
|
749
|
+
toUpdate.push({
|
|
750
|
+
path: key,
|
|
751
|
+
before: valuesChanged
|
|
752
|
+
? stripRawColorFromValues(figmaToken.values)
|
|
753
|
+
: meta.before,
|
|
754
|
+
after: valuesChanged ? codeToken.values : meta.after,
|
|
755
|
+
changes: {
|
|
756
|
+
values: valuesChanged,
|
|
757
|
+
scopes: meta.scopesChanged,
|
|
758
|
+
codeSyntax: meta.codeSyntaxChanged,
|
|
759
|
+
},
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
else {
|
|
764
|
+
unchanged++;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
for (const key of figmaTokens.keys()) {
|
|
768
|
+
if (!codeTokens.has(key) && !renamedFigmaKeys.has(key)) {
|
|
769
|
+
// Reports as "would delete if strategy=replace" but defaults to
|
|
770
|
+
// preserve under merge strategy. Keys consumed by a rename are NOT
|
|
771
|
+
// deletions — the variable lives on under its new name.
|
|
772
|
+
toDelete.push({ path: key });
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
return { toCreate, toUpdate, toRename, toDelete, unchanged };
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Structural equality for a token's mode-keyed values map. Order-independent
|
|
779
|
+
* so two tokens that have the same modes with the same values produce a
|
|
780
|
+
* match regardless of object insertion order.
|
|
781
|
+
*
|
|
782
|
+
* Recursive for composite values (typography, shadow) — those have nested
|
|
783
|
+
* objects too. Aliases are equal when both have the same `reference` string;
|
|
784
|
+
* literals are equal by deep value comparison.
|
|
785
|
+
*
|
|
786
|
+
* Each side is canonicalized to a dialect-agnostic form before comparing
|
|
787
|
+
* (see canonicalizeTokenValueForComparison): a DTCG 2025.10 color object
|
|
788
|
+
* equals the same color's legacy hex string (both quantized to 1/255 per
|
|
789
|
+
* channel), `{ value: 16, unit: "px" }` equals bare 16, and the transient
|
|
790
|
+
* rawColor field is ignored. Without this, importing a 2025-dialect file
|
|
791
|
+
* would report EVERY color as toUpdate on EVERY import, forever.
|
|
792
|
+
*/
|
|
793
|
+
function valuesEqual(a, b) {
|
|
794
|
+
const aKeys = Object.keys(a).sort();
|
|
795
|
+
const bKeys = Object.keys(b).sort();
|
|
796
|
+
if (aKeys.length !== bKeys.length)
|
|
797
|
+
return false;
|
|
798
|
+
for (let i = 0; i < aKeys.length; i++) {
|
|
799
|
+
if (aKeys[i] !== bKeys[i])
|
|
800
|
+
return false;
|
|
801
|
+
if (!deepEqual(canonicalizeTokenValueForComparison(a[aKeys[i]]), canonicalizeTokenValueForComparison(b[bKeys[i]]))) {
|
|
802
|
+
return false;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
807
|
+
function deepEqual(a, b) {
|
|
808
|
+
if (a === b)
|
|
809
|
+
return true;
|
|
810
|
+
if (typeof a !== typeof b)
|
|
811
|
+
return false;
|
|
812
|
+
if (a === null || b === null)
|
|
813
|
+
return a === b;
|
|
814
|
+
if (typeof a !== "object")
|
|
815
|
+
return a === b;
|
|
816
|
+
if (Array.isArray(a)) {
|
|
817
|
+
if (!Array.isArray(b) || a.length !== b.length)
|
|
818
|
+
return false;
|
|
819
|
+
return a.every((v, i) => deepEqual(v, b[i]));
|
|
820
|
+
}
|
|
821
|
+
const aObj = a;
|
|
822
|
+
const bObj = b;
|
|
823
|
+
const aKeys = Object.keys(aObj).sort();
|
|
824
|
+
const bKeys = Object.keys(bObj).sort();
|
|
825
|
+
if (aKeys.length !== bKeys.length)
|
|
826
|
+
return false;
|
|
827
|
+
for (let i = 0; i < aKeys.length; i++) {
|
|
828
|
+
if (aKeys[i] !== bKeys[i])
|
|
829
|
+
return false;
|
|
830
|
+
if (!deepEqual(aObj[aKeys[i]], bObj[bKeys[i]]))
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
return true;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Compare variable metadata (scopes + codeSyntax) between the Figma-side and
|
|
837
|
+
* code-side tokens.
|
|
838
|
+
*
|
|
839
|
+
* Semantics (deliberately merge-friendly):
|
|
840
|
+
* - Code-side ABSENT field = "no opinion" — never a change, so token files
|
|
841
|
+
* that predate this feature (or hand-authored files without extensions)
|
|
842
|
+
* can't silently reset Figma-side scopes/codeSyntax.
|
|
843
|
+
* - Scopes compare order-insensitively; []/["ALL_SCOPES"]/absent all
|
|
844
|
+
* normalize to the default (export omits the default, so a round-trip of
|
|
845
|
+
* an ALL_SCOPES variable is absent on both sides → unchanged).
|
|
846
|
+
* - codeSyntax compares by deep equality ({} counts as an explicit "clear
|
|
847
|
+
* every platform" opinion; absent counts as no opinion).
|
|
848
|
+
*/
|
|
849
|
+
function diffVariableMeta(figmaToken, codeToken) {
|
|
850
|
+
const figmaExt = figmaToken.extensions?.["figma-console-mcp"] ?? {};
|
|
851
|
+
const codeExt = codeToken.extensions?.["figma-console-mcp"] ?? {};
|
|
852
|
+
let scopesChanged = false;
|
|
853
|
+
if (Array.isArray(codeExt.scopes)) {
|
|
854
|
+
const codeScopes = normalizeScopesForComparison(codeExt.scopes);
|
|
855
|
+
const figmaScopes = normalizeScopesForComparison(figmaExt.scopes);
|
|
856
|
+
scopesChanged = !deepEqual(codeScopes, figmaScopes);
|
|
857
|
+
}
|
|
858
|
+
let codeSyntaxChanged = false;
|
|
859
|
+
if (codeExt.codeSyntax !== undefined &&
|
|
860
|
+
codeExt.codeSyntax !== null &&
|
|
861
|
+
typeof codeExt.codeSyntax === "object" &&
|
|
862
|
+
!Array.isArray(codeExt.codeSyntax)) {
|
|
863
|
+
codeSyntaxChanged = !deepEqual(codeExt.codeSyntax, figmaExt.codeSyntax ?? {});
|
|
864
|
+
}
|
|
865
|
+
const before = {};
|
|
866
|
+
const after = {};
|
|
867
|
+
if (scopesChanged) {
|
|
868
|
+
before.scopes = figmaExt.scopes ?? ["ALL_SCOPES"];
|
|
869
|
+
after.scopes = codeExt.scopes;
|
|
870
|
+
}
|
|
871
|
+
if (codeSyntaxChanged) {
|
|
872
|
+
before.codeSyntax = figmaExt.codeSyntax ?? {};
|
|
873
|
+
after.codeSyntax = codeExt.codeSyntax;
|
|
874
|
+
}
|
|
875
|
+
return { scopesChanged, codeSyntaxChanged, before, after };
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Normalize a scopes array to a sorted, default-collapsed form: absent,
|
|
879
|
+
* empty, and ["ALL_SCOPES"] all mean "the default scoping" in Figma.
|
|
880
|
+
*/
|
|
881
|
+
function normalizeScopesForComparison(scopes) {
|
|
882
|
+
if (!Array.isArray(scopes) || scopes.length === 0)
|
|
883
|
+
return ["ALL_SCOPES"];
|
|
884
|
+
const cleaned = scopes.filter((s) => typeof s === "string");
|
|
885
|
+
if (cleaned.length === 0)
|
|
886
|
+
return ["ALL_SCOPES"];
|
|
887
|
+
if (cleaned.length === 1 && cleaned[0] === "ALL_SCOPES")
|
|
888
|
+
return ["ALL_SCOPES"];
|
|
889
|
+
return [...cleaned].sort();
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Build a resolver that maps a DTCG alias reference (set-qualified
|
|
893
|
+
* `{theme.color.primary}`, bare `{color.primary}` when unambiguous, or the
|
|
894
|
+
* converter's `{__library:VariableID:...}` cross-library form) to a Figma
|
|
895
|
+
* variable ID.
|
|
896
|
+
*
|
|
897
|
+
* Resolution priority for a matched code-side token:
|
|
898
|
+
* 1. a variable created earlier in THIS apply run (createdIdByKey — the
|
|
899
|
+
* resolver closes over the mutable map, so the create phase's results
|
|
900
|
+
* are visible to the later update phase)
|
|
901
|
+
* 2. the live Figma snapshot's variable ID for the same set::path
|
|
902
|
+
* 3. "pending" when the target is queued in this run's toCreate batch
|
|
903
|
+
* 4. the token's own $extensions variableId (stale-but-recorded fallback)
|
|
904
|
+
* References that don't match a code-side token fall back to the live Figma
|
|
905
|
+
* snapshot lookup. Exported for test coverage.
|
|
906
|
+
*/
|
|
907
|
+
export function makeAliasIdResolver(figmaDoc, codeDoc, toCreateKeys, createdIdByKey) {
|
|
908
|
+
const codeLookup = buildTokenLookup(codeDoc);
|
|
909
|
+
const figmaLookup = buildTokenLookup(figmaDoc);
|
|
910
|
+
// "SetName::dot.path" → live Figma variable ID.
|
|
911
|
+
const figmaIdByKey = new Map();
|
|
912
|
+
for (const set of figmaDoc.sets) {
|
|
913
|
+
for (const t of set.tokens) {
|
|
914
|
+
const id = t.extensions?.["figma-console-mcp"]?.variableId;
|
|
915
|
+
if (typeof id === "string") {
|
|
916
|
+
figmaIdByKey.set(`${set.name}::${t.path.join(".")}`, id);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return (reference) => {
|
|
921
|
+
const bare = reference.replace(/^\{|\}$/g, "");
|
|
922
|
+
// Cross-library references preserve the original Figma variable ID —
|
|
923
|
+
// usable directly as an alias target.
|
|
924
|
+
if (bare.startsWith("__library:")) {
|
|
925
|
+
return { id: bare.slice("__library:".length) };
|
|
926
|
+
}
|
|
927
|
+
const codeEntry = codeLookup.get(bare);
|
|
928
|
+
if (codeEntry) {
|
|
929
|
+
const key = `${codeEntry.setName}::${codeEntry.token.path.join(".")}`;
|
|
930
|
+
const createdId = createdIdByKey.get(key);
|
|
931
|
+
if (createdId)
|
|
932
|
+
return { id: createdId };
|
|
933
|
+
const liveId = figmaIdByKey.get(key);
|
|
934
|
+
if (liveId)
|
|
935
|
+
return { id: liveId };
|
|
936
|
+
if (toCreateKeys.has(key))
|
|
937
|
+
return { pending: key };
|
|
938
|
+
const extId = codeEntry.token.extensions?.["figma-console-mcp"]?.variableId;
|
|
939
|
+
if (typeof extId === "string")
|
|
940
|
+
return { id: extId };
|
|
941
|
+
return null;
|
|
942
|
+
}
|
|
943
|
+
// Not in the code document — the reference may point at a Figma-only
|
|
944
|
+
// token (present in the live snapshot but absent from the import file).
|
|
945
|
+
const figmaEntry = figmaLookup.get(bare);
|
|
946
|
+
if (figmaEntry) {
|
|
947
|
+
const id = figmaEntry.token.extensions?.["figma-console-mcp"]?.variableId;
|
|
948
|
+
if (typeof id === "string")
|
|
949
|
+
return { id };
|
|
950
|
+
}
|
|
951
|
+
return null;
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Build a quick lookup of (collectionId, modeName) → modeId from the raw
|
|
956
|
+
* Figma payload. Needed because our internal model is keyed by mode name
|
|
957
|
+
* but the Plugin API wants the modeId.
|
|
958
|
+
*/
|
|
959
|
+
function buildCollectionModeMap(payload) {
|
|
960
|
+
const out = new Map();
|
|
961
|
+
for (const c of payload.collections) {
|
|
962
|
+
const modes = new Map();
|
|
963
|
+
for (const m of c.modes ?? []) {
|
|
964
|
+
modes.set(m.name, m.modeId);
|
|
965
|
+
}
|
|
966
|
+
out.set(c.id, modes);
|
|
967
|
+
}
|
|
968
|
+
return out;
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Convert a TokenValue back to Figma's native value shape. Required for the
|
|
972
|
+
* Plugin API's setValueForMode call.
|
|
973
|
+
*
|
|
974
|
+
* - color hex string "#RRGGBB(AA)" → { r, g, b, a } floats in [0, 1].
|
|
975
|
+
* Non-hex color strings ("rgb(255,0,0)", "transparent", named colors
|
|
976
|
+
* like "salmon") return skip-invalid instead of throwing — a throw here
|
|
977
|
+
* would abort the import mid-apply.
|
|
978
|
+
* - DTCG 2025.10 color objects → { r, g, b, a }: srgb components map
|
|
979
|
+
* directly (clamped to [0, 1]); non-srgb colorSpaces (display-p3,
|
|
980
|
+
* oklch, …) fall back to the object's `hex` field when present, else
|
|
981
|
+
* skip-invalid; hex-only objects are accepted too
|
|
982
|
+
* - FLOAT-typed number → number ("16px"-style strings and DTCG
|
|
983
|
+
* { value, unit } objects are parsed to their numeric part; anything
|
|
984
|
+
* that still comes out NaN is skipped rather than pushed to Figma)
|
|
985
|
+
* - STRING-typed string → string
|
|
986
|
+
* - BOOLEAN → boolean
|
|
987
|
+
* - Alias references return `skip-alias` so the CALLER resolves them —
|
|
988
|
+
* both apply paths pass the reference through their alias-ID resolver
|
|
989
|
+
* (just-created variable → live snapshot → pending in batch → recorded
|
|
990
|
+
* $extensions ID) and write { type: "VARIABLE_ALIAS", id }. Only when
|
|
991
|
+
* the resolver comes up empty does the reference stay skipped, with a
|
|
992
|
+
* warning rather than a silent drop.
|
|
993
|
+
*
|
|
994
|
+
* Exported for test coverage of the value-conversion edge cases.
|
|
995
|
+
*/
|
|
996
|
+
export function tokenValueToFigma(value, resolvedType) {
|
|
997
|
+
if (value.reference) {
|
|
998
|
+
// References aren't converted here — the caller resolves them to a
|
|
999
|
+
// Figma variable ID via its alias-ID resolver and writes a real
|
|
1000
|
+
// { type: "VARIABLE_ALIAS", id } payload. Returning skip-alias hands
|
|
1001
|
+
// the reference back for that resolution; if the resolver can't find
|
|
1002
|
+
// a target, the caller surfaces a warning instead of silently wiping
|
|
1003
|
+
// the reference with a literal.
|
|
1004
|
+
return { kind: "skip-alias", reference: value.reference };
|
|
1005
|
+
}
|
|
1006
|
+
if (value.literal === undefined || value.literal === null) {
|
|
1007
|
+
return { kind: "skip-empty" };
|
|
1008
|
+
}
|
|
1009
|
+
let figmaValue;
|
|
1010
|
+
if (resolvedType === "COLOR" && typeof value.literal === "string") {
|
|
1011
|
+
// Guarded: hexToRgba throws on anything that isn't a hex color
|
|
1012
|
+
// ("rgb(255,0,0)", "transparent", "salmon", "oklch(...)"). A throw here
|
|
1013
|
+
// would abort the whole import AFTER the create phase already mutated
|
|
1014
|
+
// Figma — so malformed colors become a per-value skip instead.
|
|
1015
|
+
try {
|
|
1016
|
+
figmaValue = hexToRgba(value.literal);
|
|
1017
|
+
}
|
|
1018
|
+
catch (err) {
|
|
1019
|
+
return {
|
|
1020
|
+
kind: "skip-invalid",
|
|
1021
|
+
reason: `cannot convert ${JSON.stringify(value.literal)} to a Figma color (${err instanceof Error ? err.message : String(err)})`,
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
else if (resolvedType === "COLOR" &&
|
|
1026
|
+
typeof value.literal === "object" &&
|
|
1027
|
+
value.literal !== null &&
|
|
1028
|
+
!Array.isArray(value.literal)) {
|
|
1029
|
+
// DTCG 2025.10 object-form color. Without this branch the object fell
|
|
1030
|
+
// through to String(literal) → "[object Object]" pushed at a COLOR
|
|
1031
|
+
// variable.
|
|
1032
|
+
const converted = colorObjectToFigmaRgba(value.literal);
|
|
1033
|
+
if (converted.kind !== "value")
|
|
1034
|
+
return converted;
|
|
1035
|
+
figmaValue = converted.value;
|
|
1036
|
+
}
|
|
1037
|
+
else if (resolvedType === "FLOAT") {
|
|
1038
|
+
const parsed = parseNumericLiteral(value.literal);
|
|
1039
|
+
if (Number.isNaN(parsed)) {
|
|
1040
|
+
// Never push NaN into setValueForMode — skip with a reason instead.
|
|
1041
|
+
return {
|
|
1042
|
+
kind: "skip-invalid",
|
|
1043
|
+
reason: `cannot convert ${JSON.stringify(value.literal)} to a number for a FLOAT variable`,
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
figmaValue = parsed;
|
|
1047
|
+
}
|
|
1048
|
+
else if (resolvedType === "BOOLEAN") {
|
|
1049
|
+
figmaValue = Boolean(value.literal);
|
|
1050
|
+
}
|
|
1051
|
+
else {
|
|
1052
|
+
figmaValue = typeof value.literal === "string" ? value.literal : String(value.literal);
|
|
1053
|
+
}
|
|
1054
|
+
return { kind: "value", value: figmaValue };
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Convert a DTCG 2025.10 color object literal to Figma's { r, g, b, a }
|
|
1058
|
+
* floats. Handles, in priority order:
|
|
1059
|
+
* 1. srgb (or unspecified) colorSpace + 3 numeric components → direct
|
|
1060
|
+
* mapping, each channel clamped to [0, 1]; optional `alpha` (default 1)
|
|
1061
|
+
* 2. any object with a `hex` string field (covers non-srgb colorSpaces
|
|
1062
|
+
* like display-p3/oklch that carry the hex interop fallback, and
|
|
1063
|
+
* hex-only objects) → hexToRgba, with the `alpha` field taking
|
|
1064
|
+
* precedence over hex-embedded alpha when present
|
|
1065
|
+
* 3. non-srgb colorSpace without a hex fallback → skip-invalid (we can't
|
|
1066
|
+
* do color-space conversion, and guessing would corrupt the variable)
|
|
1067
|
+
*/
|
|
1068
|
+
function colorObjectToFigmaRgba(obj) {
|
|
1069
|
+
const colorSpace = typeof obj.colorSpace === "string" ? obj.colorSpace : undefined;
|
|
1070
|
+
const comps = obj.components;
|
|
1071
|
+
if ((colorSpace === undefined || colorSpace === "srgb") &&
|
|
1072
|
+
Array.isArray(comps) &&
|
|
1073
|
+
comps.length === 3 &&
|
|
1074
|
+
comps.every((c) => typeof c === "number")) {
|
|
1075
|
+
const [r, g, b] = comps.map(clamp01);
|
|
1076
|
+
const a = typeof obj.alpha === "number" ? clamp01(obj.alpha) : 1;
|
|
1077
|
+
return { kind: "value", value: { r, g, b, a } };
|
|
1078
|
+
}
|
|
1079
|
+
if (typeof obj.hex === "string") {
|
|
1080
|
+
try {
|
|
1081
|
+
const rgba = hexToRgba(obj.hex);
|
|
1082
|
+
if (typeof obj.alpha === "number")
|
|
1083
|
+
rgba.a = clamp01(obj.alpha);
|
|
1084
|
+
return { kind: "value", value: rgba };
|
|
1085
|
+
}
|
|
1086
|
+
catch {
|
|
1087
|
+
return {
|
|
1088
|
+
kind: "skip-invalid",
|
|
1089
|
+
reason: `color object has an unparseable hex field ${JSON.stringify(obj.hex)}`,
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
if (colorSpace !== undefined && colorSpace !== "srgb") {
|
|
1094
|
+
return {
|
|
1095
|
+
kind: "skip-invalid",
|
|
1096
|
+
reason: `unsupported colorSpace "${colorSpace}" without hex fallback`,
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
return {
|
|
1100
|
+
kind: "skip-invalid",
|
|
1101
|
+
reason: `cannot convert ${JSON.stringify(obj)} to a Figma color — expected srgb components or a hex field`,
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Parse a token literal into a number for FLOAT variables. Handles:
|
|
1106
|
+
* - plain numbers → as-is
|
|
1107
|
+
* - unit-bearing dimension strings ("16px", "1.5rem", "-4pt") → numeric part
|
|
1108
|
+
* - DTCG dimension AND duration objects: { value: 16, unit: "px" } → 16,
|
|
1109
|
+
* { value: 300, unit: "ms" } → 300, and { value: 0.3, unit: "s" } → 300
|
|
1110
|
+
* — seconds MULTIPLY to milliseconds so the write path agrees with the
|
|
1111
|
+
* diff canonicalization (canonicalizeTokenValueForComparison, which
|
|
1112
|
+
* treats {0.3, "s"} ≡ {300, "ms"} ≡ 300). If the write path took the
|
|
1113
|
+
* raw 0.3 instead, the diff would report a change, apply would write a
|
|
1114
|
+
* 1000x-wrong value, and every subsequent import would rewrite it
|
|
1115
|
+
* forever. Other units (px/rem/…) keep the raw value — Figma FLOAT is
|
|
1116
|
+
* unitless. True TIMING-typed variables are skipped before import ever
|
|
1117
|
+
* reaches this path (Plugin API can't write them), so plain FLOAT is
|
|
1118
|
+
* the only consumer here.
|
|
1119
|
+
* Anything unparseable returns NaN — callers must skip (never push NaN
|
|
1120
|
+
* into setValueForMode).
|
|
1121
|
+
*/
|
|
1122
|
+
function parseNumericLiteral(literal) {
|
|
1123
|
+
if (typeof literal === "number")
|
|
1124
|
+
return literal;
|
|
1125
|
+
if (typeof literal === "string") {
|
|
1126
|
+
const match = literal
|
|
1127
|
+
.trim()
|
|
1128
|
+
.match(/^(-?(?:\d+\.?\d*|\.\d+))\s*(px|rem|em|pt|dp|ms|s|%)?$/i);
|
|
1129
|
+
if (match)
|
|
1130
|
+
return Number(match[1]);
|
|
1131
|
+
return Number(literal);
|
|
1132
|
+
}
|
|
1133
|
+
if (literal !== null &&
|
|
1134
|
+
typeof literal === "object" &&
|
|
1135
|
+
"value" in literal) {
|
|
1136
|
+
const obj = literal;
|
|
1137
|
+
const inner = parseNumericLiteral(obj.value);
|
|
1138
|
+
// s → ms, matching the diff-side canonicalization (see doc above).
|
|
1139
|
+
if (obj.unit === "s")
|
|
1140
|
+
return inner * 1000;
|
|
1141
|
+
return inner;
|
|
1142
|
+
}
|
|
1143
|
+
return Number(literal);
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Parse a hex color string to Figma rgba floats. Validates through the
|
|
1147
|
+
* dialect module's colorLiteralToCanonicalHex (via hexToRawRgba) so ONLY
|
|
1148
|
+
* genuine 3/6/8-digit hex strings pass — the previous implementation
|
|
1149
|
+
* dispatched on string LENGTH alone, so named colors like "red" (3 chars)
|
|
1150
|
+
* or "salmon" (6 chars) produced NaN channels that reached setValueForMode.
|
|
1151
|
+
* A bare hex without the leading "#" is still tolerated (historical
|
|
1152
|
+
* behavior). Throws on anything else; tokenValueToFigma catches and turns
|
|
1153
|
+
* it into a skip-invalid.
|
|
1154
|
+
*/
|
|
1155
|
+
function hexToRgba(hex) {
|
|
1156
|
+
const trimmed = hex.trim();
|
|
1157
|
+
const normalized = trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
|
|
1158
|
+
const rgba = hexToRawRgba(normalized);
|
|
1159
|
+
if (!rgba) {
|
|
1160
|
+
throw new Error(`[figma-console-mcp] Invalid hex color ${JSON.stringify(hex)} — expected #RGB, #RRGGBB, or #RRGGBBAA.`);
|
|
1161
|
+
}
|
|
1162
|
+
return rgba;
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Walk the toUpdate diff entries and translate each into a VariableUpdate.
|
|
1166
|
+
* Tokens that lack a Figma variable ID (never been synced) or have no
|
|
1167
|
+
* resolvable value for any mode get skipped with a warning.
|
|
1168
|
+
*/
|
|
1169
|
+
function buildUpdatePayloads(toUpdate, figmaDoc, codeDoc, collectionModeMap, warnings, resolveAliasId) {
|
|
1170
|
+
// Build lookups: setName::tokenPath → (figmaToken, codeToken)
|
|
1171
|
+
const figmaLookup = new Map();
|
|
1172
|
+
for (const set of figmaDoc.sets) {
|
|
1173
|
+
for (const t of set.tokens) {
|
|
1174
|
+
figmaLookup.set(`${set.name}::${t.path.join(".")}`, { token: t, set });
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
const codeLookup = new Map();
|
|
1178
|
+
for (const set of codeDoc.sets) {
|
|
1179
|
+
for (const t of set.tokens) {
|
|
1180
|
+
codeLookup.set(`${set.name}::${t.path.join(".")}`, { token: t, set });
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
const updates = [];
|
|
1184
|
+
for (const entry of toUpdate) {
|
|
1185
|
+
// Rename entries look up the code token at its NEW path (codePath) and
|
|
1186
|
+
// the Figma variable at its OLD path (path).
|
|
1187
|
+
const codeMatch = codeLookup.get(entry.codePath ?? entry.path);
|
|
1188
|
+
const figmaMatch = figmaLookup.get(entry.path);
|
|
1189
|
+
if (!codeMatch || !figmaMatch)
|
|
1190
|
+
continue;
|
|
1191
|
+
const figmaToken = figmaMatch.token;
|
|
1192
|
+
const variableId = figmaToken.extensions?.["figma-console-mcp"]?.variableId;
|
|
1193
|
+
const collectionId = figmaToken.extensions?.["figma-console-mcp"]?.collectionId;
|
|
1194
|
+
if (!variableId || !collectionId) {
|
|
1195
|
+
warnings.push(`Cannot update ${entry.path} — missing Figma variable ID in extensions. Run figma_export_tokens first to populate.`);
|
|
1196
|
+
continue;
|
|
1197
|
+
}
|
|
1198
|
+
const modeMap = collectionModeMap.get(collectionId);
|
|
1199
|
+
if (!modeMap) {
|
|
1200
|
+
warnings.push(`Cannot update ${entry.path} — collection ${collectionId} not found in current Figma state.`);
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
// Map our token type → Figma resolvedType. Prefer the actual
|
|
1204
|
+
// Figma-native type recorded at export time
|
|
1205
|
+
// (extensions.figmaResolvedType) — critical so FLOAT variables whose
|
|
1206
|
+
// token type was name-inferred as "duration" keep writing as FLOAT,
|
|
1207
|
+
// while true TIMING/EASING variables are recognized. Fall back to
|
|
1208
|
+
// inferring from the DTCG type when the extension is absent.
|
|
1209
|
+
const recordedType = figmaToken.extensions?.["figma-console-mcp"]?.figmaResolvedType;
|
|
1210
|
+
const figmaNativeType = typeof recordedType === "string"
|
|
1211
|
+
? recordedType
|
|
1212
|
+
: inferFigmaResolvedType(figmaToken.type);
|
|
1213
|
+
if (figmaNativeType === "TIMING" || figmaNativeType === "EASING") {
|
|
1214
|
+
// The Figma Plugin API cannot create or setValueForMode on
|
|
1215
|
+
// TIMING/EASING variables — only BOOLEAN/COLOR/FLOAT/STRING are
|
|
1216
|
+
// writable. Sending these would be rejected (or worse); skip loudly.
|
|
1217
|
+
warnings.push(`Skipped ${entry.path} — Figma Plugin API cannot write ${figmaNativeType === "TIMING" ? "Timing" : "Easing"} variables (only BOOLEAN/COLOR/FLOAT/STRING are writable). Update this variable in the Figma UI instead.`);
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
const resolvedType = figmaNativeType;
|
|
1221
|
+
// Value updates apply unless the diff explicitly flagged this entry as
|
|
1222
|
+
// metadata-only (changes.values === false) — re-pushing unchanged values
|
|
1223
|
+
// would be wasteful and could trip alias-resolution warnings.
|
|
1224
|
+
const wantValues = !entry.changes || entry.changes.values;
|
|
1225
|
+
const valuesByMode = {};
|
|
1226
|
+
const valueEntries = wantValues
|
|
1227
|
+
? Object.entries(codeMatch.token.values)
|
|
1228
|
+
: [];
|
|
1229
|
+
for (const [modeName, value] of valueEntries) {
|
|
1230
|
+
const modeId = modeMap.get(modeName);
|
|
1231
|
+
if (!modeId) {
|
|
1232
|
+
warnings.push(`Cannot update ${entry.path} (mode "${modeName}") — modeId not found in Figma collection.`);
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
const conversion = tokenValueToFigma(value, resolvedType);
|
|
1236
|
+
if (conversion.kind === "skip-alias") {
|
|
1237
|
+
// Alias-target update: resolve the reference to a Figma variable ID
|
|
1238
|
+
// and write it as a native variable alias. Creates run before
|
|
1239
|
+
// updates, so references to just-created variables resolve too.
|
|
1240
|
+
const resolved = resolveAliasId?.(conversion.reference);
|
|
1241
|
+
if (resolved && "id" in resolved) {
|
|
1242
|
+
valuesByMode[modeId] = { type: "VARIABLE_ALIAS", id: resolved.id };
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
warnings.push(`Skipped ${entry.path} (mode "${modeName}") — alias reference "${conversion.reference}" could not be resolved to an existing or newly-created Figma variable. Fix the reference, or edit the alias target's value instead.`);
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1248
|
+
if (conversion.kind === "skip-invalid") {
|
|
1249
|
+
warnings.push(`Skipped ${entry.path} (mode "${modeName}") — ${conversion.reason}.`);
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
if (conversion.kind === "skip-empty")
|
|
1253
|
+
continue;
|
|
1254
|
+
valuesByMode[modeId] = conversion.value;
|
|
1255
|
+
}
|
|
1256
|
+
// Metadata ops (scopes / codeSyntax) — flagged by the diff phase.
|
|
1257
|
+
let scopes;
|
|
1258
|
+
if (entry.changes?.scopes) {
|
|
1259
|
+
const codeScopes = codeMatch.token.extensions?.["figma-console-mcp"]?.scopes;
|
|
1260
|
+
if (Array.isArray(codeScopes) && codeScopes.length > 0) {
|
|
1261
|
+
scopes = codeScopes;
|
|
1262
|
+
}
|
|
1263
|
+
else {
|
|
1264
|
+
// Explicit empty/["ALL_SCOPES"] normalizes to the Figma default.
|
|
1265
|
+
scopes = ["ALL_SCOPES"];
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
let codeSyntaxOps;
|
|
1269
|
+
if (entry.changes?.codeSyntax) {
|
|
1270
|
+
const codeCS = codeMatch.token.extensions?.["figma-console-mcp"]?.codeSyntax ?? {};
|
|
1271
|
+
const figmaCS = figmaToken.extensions?.["figma-console-mcp"]?.codeSyntax ?? {};
|
|
1272
|
+
const toSet = {};
|
|
1273
|
+
for (const [platform, value] of Object.entries(codeCS)) {
|
|
1274
|
+
if (typeof value === "string" && figmaCS[platform] !== value) {
|
|
1275
|
+
toSet[platform] = value;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
const toRemove = Object.keys(figmaCS).filter((p) => !(p in codeCS));
|
|
1279
|
+
if (Object.keys(toSet).length > 0 || toRemove.length > 0) {
|
|
1280
|
+
codeSyntaxOps = {
|
|
1281
|
+
...(Object.keys(toSet).length > 0 ? { set: toSet } : {}),
|
|
1282
|
+
...(toRemove.length > 0 ? { remove: toRemove } : {}),
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
if (Object.keys(valuesByMode).length === 0 &&
|
|
1287
|
+
scopes === undefined &&
|
|
1288
|
+
codeSyntaxOps === undefined &&
|
|
1289
|
+
entry.newName === undefined) {
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
updates.push({
|
|
1293
|
+
variableId,
|
|
1294
|
+
variableName: figmaToken.path.join("/"),
|
|
1295
|
+
resolvedType,
|
|
1296
|
+
valuesByMode,
|
|
1297
|
+
...(entry.newName !== undefined ? { newName: entry.newName } : {}),
|
|
1298
|
+
...(scopes !== undefined ? { scopes } : {}),
|
|
1299
|
+
...(codeSyntaxOps !== undefined ? { codeSyntax: codeSyntaxOps } : {}),
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
return updates;
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Map our internal TokenType to Figma's variable resolvedType.
|
|
1306
|
+
*
|
|
1307
|
+
* duration/cubicBezier map to the Config-2026 TIMING/EASING variable types
|
|
1308
|
+
* — but note the Figma Plugin API CANNOT create or setValueForMode on
|
|
1309
|
+
* TIMING/EASING variables (only BOOLEAN/COLOR/FLOAT/STRING are writable),
|
|
1310
|
+
* so callers must skip those with a warning instead of pushing them.
|
|
1311
|
+
* buildUpdatePayloads prefers the extension-recorded figmaResolvedType over
|
|
1312
|
+
* this inference, so FLOAT variables whose token type was name-inferred as
|
|
1313
|
+
* "duration" still write correctly as FLOAT.
|
|
1314
|
+
*/
|
|
1315
|
+
function inferFigmaResolvedType(type) {
|
|
1316
|
+
if (type === "color")
|
|
1317
|
+
return "COLOR";
|
|
1318
|
+
if (type === "boolean")
|
|
1319
|
+
return "BOOLEAN";
|
|
1320
|
+
if (type === "string" || type === "fontFamily")
|
|
1321
|
+
return "STRING";
|
|
1322
|
+
if (type === "duration")
|
|
1323
|
+
return "TIMING";
|
|
1324
|
+
if (type === "cubicBezier")
|
|
1325
|
+
return "EASING";
|
|
1326
|
+
return "FLOAT"; // dimension, number, fontWeight, etc.
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Push variable updates to Figma via executeCodeViaUI. The plugin runs the
|
|
1330
|
+
* inline script in its sandbox, calling figma.variables.setValueForMode for
|
|
1331
|
+
* each (variableId, modeId, value) tuple.
|
|
1332
|
+
*/
|
|
1333
|
+
async function applyUpdates(connector, updates) {
|
|
1334
|
+
// Serialize the update list into the script payload. JSON.stringify
|
|
1335
|
+
// handles escape correctly even with nested objects (RGBA color values).
|
|
1336
|
+
const payload = JSON.stringify(updates);
|
|
1337
|
+
const script = `
|
|
1338
|
+
const updates = ${payload};
|
|
1339
|
+
const results = [];
|
|
1340
|
+
for (const u of updates) {
|
|
1341
|
+
try {
|
|
1342
|
+
const variable = await figma.variables.getVariableByIdAsync(u.variableId);
|
|
1343
|
+
if (!variable) {
|
|
1344
|
+
results.push({ id: u.variableId, success: false, error: "Variable not found in current file" });
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1347
|
+
// Rename FIRST — a rename entry may carry no value writes at all.
|
|
1348
|
+
if (u.newName) {
|
|
1349
|
+
variable.name = u.newName;
|
|
1350
|
+
}
|
|
1351
|
+
let appliedModes = 0;
|
|
1352
|
+
for (const modeId in u.valuesByMode) {
|
|
1353
|
+
variable.setValueForMode(modeId, u.valuesByMode[modeId]);
|
|
1354
|
+
appliedModes++;
|
|
1355
|
+
}
|
|
1356
|
+
// Metadata writes — scopes replace wholesale; codeSyntax applies
|
|
1357
|
+
// per-platform sets then removals (removeVariableCodeSyntax is the
|
|
1358
|
+
// Plugin API's deletion primitive).
|
|
1359
|
+
if (u.scopes) {
|
|
1360
|
+
variable.scopes = u.scopes;
|
|
1361
|
+
}
|
|
1362
|
+
if (u.codeSyntax) {
|
|
1363
|
+
if (u.codeSyntax.set) {
|
|
1364
|
+
for (const platform in u.codeSyntax.set) {
|
|
1365
|
+
variable.setVariableCodeSyntax(platform, u.codeSyntax.set[platform]);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
if (u.codeSyntax.remove) {
|
|
1369
|
+
for (const platform of u.codeSyntax.remove) {
|
|
1370
|
+
variable.removeVariableCodeSyntax(platform);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
results.push({ id: u.variableId, name: variable.name, success: true, appliedModes });
|
|
1375
|
+
} catch (err) {
|
|
1376
|
+
results.push({ id: u.variableId, success: false, error: String(err && err.message || err) });
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
return {
|
|
1380
|
+
applied: results.filter(r => r.success).length,
|
|
1381
|
+
failed: results.filter(r => !r.success).length,
|
|
1382
|
+
results,
|
|
1383
|
+
};
|
|
1384
|
+
`;
|
|
1385
|
+
const execResult = await connector.executeCodeViaUI(script, 30000);
|
|
1386
|
+
if (!execResult?.success) {
|
|
1387
|
+
return {
|
|
1388
|
+
applied: 0,
|
|
1389
|
+
failed: updates.length,
|
|
1390
|
+
errors: [
|
|
1391
|
+
{
|
|
1392
|
+
variableId: "<batch>",
|
|
1393
|
+
error: execResult?.error ??
|
|
1394
|
+
"Plugin executeCodeViaUI returned an error or timed out.",
|
|
1395
|
+
},
|
|
1396
|
+
],
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
const inner = execResult.result ?? execResult;
|
|
1400
|
+
const errors = (inner.results ?? [])
|
|
1401
|
+
.filter((r) => !r.success)
|
|
1402
|
+
.map((r) => ({ variableId: r.id, error: r.error }));
|
|
1403
|
+
return {
|
|
1404
|
+
applied: inner.applied ?? 0,
|
|
1405
|
+
failed: inner.failed ?? 0,
|
|
1406
|
+
errors,
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
const WRITABLE_RESOLVED_TYPES = new Set(["COLOR", "FLOAT", "STRING", "BOOLEAN"]);
|
|
1410
|
+
/**
|
|
1411
|
+
* Translate the diff's toCreate entries into a CreatePlan.
|
|
1412
|
+
*
|
|
1413
|
+
* - Sets with no matching Figma collection (matched by the round-trip
|
|
1414
|
+
* figmaCollectionId first, then by name) become newCollections carrying
|
|
1415
|
+
* the set's FULL mode list.
|
|
1416
|
+
* - Variables missing from an existing collection go to that collection,
|
|
1417
|
+
* with values pre-resolved to modeIds; values for modes the collection
|
|
1418
|
+
* doesn't have are skipped with a warning.
|
|
1419
|
+
* - resolvedType honors $extensions.figmaResolvedType when recorded, else
|
|
1420
|
+
* falls back to inferFigmaResolvedType. TIMING/EASING variables are
|
|
1421
|
+
* skipped with a warning — the Figma Plugin API cannot create them.
|
|
1422
|
+
* - Literal values convert via tokenValueToFigma (both DTCG dialects);
|
|
1423
|
+
* alias values resolve via the alias-ID resolver (existing target →
|
|
1424
|
+
* "alias", within-batch target → "alias-pending", unresolvable → skip
|
|
1425
|
+
* with warning).
|
|
1426
|
+
*
|
|
1427
|
+
* Exported for test coverage.
|
|
1428
|
+
*/
|
|
1429
|
+
export function buildCreatePlan(toCreate, codeDoc, figmaPayload, resolveAliasId, warnings) {
|
|
1430
|
+
// Code-side lookup: "SetName::dot.path" → { set, token }.
|
|
1431
|
+
const codeByKey = new Map();
|
|
1432
|
+
for (const set of codeDoc.sets) {
|
|
1433
|
+
for (const token of set.tokens) {
|
|
1434
|
+
codeByKey.set(`${set.name}::${token.path.join(".")}`, { set, token });
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
// Figma collection lookup by ID and by name.
|
|
1438
|
+
const collectionById = new Map();
|
|
1439
|
+
const collectionByName = new Map();
|
|
1440
|
+
for (const c of figmaPayload.collections) {
|
|
1441
|
+
collectionById.set(c.id, c);
|
|
1442
|
+
collectionByName.set(c.name, c);
|
|
1443
|
+
}
|
|
1444
|
+
const newBySet = new Map();
|
|
1445
|
+
const existingById = new Map();
|
|
1446
|
+
for (const entry of toCreate) {
|
|
1447
|
+
const match = codeByKey.get(entry.path);
|
|
1448
|
+
if (!match)
|
|
1449
|
+
continue; // Defensive — toCreate keys come from codeDoc.
|
|
1450
|
+
const { set, token } = match;
|
|
1451
|
+
// Which Figma collection does this set map to? Round-trip collection ID
|
|
1452
|
+
// wins; name match second; otherwise the set needs a new collection.
|
|
1453
|
+
const existingCollection = (set.meta?.figmaCollectionId
|
|
1454
|
+
? collectionById.get(set.meta.figmaCollectionId)
|
|
1455
|
+
: undefined) ?? collectionByName.get(set.name);
|
|
1456
|
+
// Resolve the Figma-native type, honoring the recorded resolvedType.
|
|
1457
|
+
const recorded = token.extensions?.["figma-console-mcp"]?.figmaResolvedType;
|
|
1458
|
+
const figmaNativeType = typeof recorded === "string" &&
|
|
1459
|
+
["COLOR", "FLOAT", "STRING", "BOOLEAN", "TIMING", "EASING"].includes(recorded)
|
|
1460
|
+
? recorded
|
|
1461
|
+
: inferFigmaResolvedType(token.type);
|
|
1462
|
+
if (!WRITABLE_RESOLVED_TYPES.has(figmaNativeType)) {
|
|
1463
|
+
warnings.push(`Skipped create for ${entry.path} — Figma Plugin API cannot create ${figmaNativeType === "TIMING" ? "Timing" : "Easing"} variables (only BOOLEAN/COLOR/FLOAT/STRING are creatable). Create this variable in the Figma UI instead.`);
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
const resolvedType = figmaNativeType;
|
|
1467
|
+
// Mode-name → modeId map for existing collections.
|
|
1468
|
+
const modeIdByName = new Map();
|
|
1469
|
+
if (existingCollection) {
|
|
1470
|
+
for (const m of existingCollection.modes ?? []) {
|
|
1471
|
+
modeIdByName.set(m.name, m.modeId);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
const values = [];
|
|
1475
|
+
for (const [modeName, value] of Object.entries(token.values)) {
|
|
1476
|
+
let modeKeying;
|
|
1477
|
+
if (existingCollection) {
|
|
1478
|
+
const modeId = modeIdByName.get(modeName);
|
|
1479
|
+
if (!modeId) {
|
|
1480
|
+
warnings.push(`Cannot set ${entry.path} (mode "${modeName}") — mode not found in existing Figma collection "${set.name}". Add the mode in Figma first (figma_add_mode).`);
|
|
1481
|
+
continue;
|
|
1482
|
+
}
|
|
1483
|
+
modeKeying = { modeId };
|
|
1484
|
+
}
|
|
1485
|
+
else {
|
|
1486
|
+
modeKeying = { modeName };
|
|
1487
|
+
}
|
|
1488
|
+
if (value?.reference) {
|
|
1489
|
+
const resolved = resolveAliasId(value.reference);
|
|
1490
|
+
if (!resolved) {
|
|
1491
|
+
warnings.push(`Skipped ${entry.path} (mode "${modeName}") — alias reference "${value.reference}" could not be resolved to an existing or newly-created Figma variable.`);
|
|
1492
|
+
continue;
|
|
1493
|
+
}
|
|
1494
|
+
if ("id" in resolved) {
|
|
1495
|
+
values.push({ ...modeKeying, kind: "alias", targetId: resolved.id });
|
|
1496
|
+
}
|
|
1497
|
+
else {
|
|
1498
|
+
values.push({
|
|
1499
|
+
...modeKeying,
|
|
1500
|
+
kind: "alias-pending",
|
|
1501
|
+
targetKey: resolved.pending,
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
continue;
|
|
1505
|
+
}
|
|
1506
|
+
const conversion = tokenValueToFigma(value, resolvedType);
|
|
1507
|
+
if (conversion.kind === "skip-invalid") {
|
|
1508
|
+
warnings.push(`Skipped ${entry.path} (mode "${modeName}") — ${conversion.reason}.`);
|
|
1509
|
+
continue;
|
|
1510
|
+
}
|
|
1511
|
+
if (conversion.kind !== "value")
|
|
1512
|
+
continue; // skip-empty / skip-alias (handled above)
|
|
1513
|
+
values.push({ ...modeKeying, kind: "literal", value: conversion.value });
|
|
1514
|
+
}
|
|
1515
|
+
// Stashed variable metadata rides along to creation. Scopes: only
|
|
1516
|
+
// meaningful (non-default) arrays; codeSyntax: only non-empty maps.
|
|
1517
|
+
const ext = token.extensions?.["figma-console-mcp"] ?? {};
|
|
1518
|
+
const createScopes = Array.isArray(ext.scopes) &&
|
|
1519
|
+
ext.scopes.length > 0 &&
|
|
1520
|
+
!(ext.scopes.length === 1 && ext.scopes[0] === "ALL_SCOPES")
|
|
1521
|
+
? ext.scopes.filter((s) => typeof s === "string")
|
|
1522
|
+
: undefined;
|
|
1523
|
+
const createCodeSyntax = ext.codeSyntax &&
|
|
1524
|
+
typeof ext.codeSyntax === "object" &&
|
|
1525
|
+
!Array.isArray(ext.codeSyntax) &&
|
|
1526
|
+
Object.keys(ext.codeSyntax).length > 0
|
|
1527
|
+
? ext.codeSyntax
|
|
1528
|
+
: undefined;
|
|
1529
|
+
const def = {
|
|
1530
|
+
key: entry.path,
|
|
1531
|
+
name: token.path.join("/"),
|
|
1532
|
+
resolvedType,
|
|
1533
|
+
...(token.description ? { description: token.description } : {}),
|
|
1534
|
+
values,
|
|
1535
|
+
...(createScopes && createScopes.length > 0
|
|
1536
|
+
? { scopes: createScopes }
|
|
1537
|
+
: {}),
|
|
1538
|
+
...(createCodeSyntax ? { codeSyntax: createCodeSyntax } : {}),
|
|
1539
|
+
};
|
|
1540
|
+
if (existingCollection) {
|
|
1541
|
+
let bucket = existingById.get(existingCollection.id);
|
|
1542
|
+
if (!bucket) {
|
|
1543
|
+
bucket = { collectionId: existingCollection.id, variables: [] };
|
|
1544
|
+
existingById.set(existingCollection.id, bucket);
|
|
1545
|
+
}
|
|
1546
|
+
bucket.variables.push(def);
|
|
1547
|
+
}
|
|
1548
|
+
else {
|
|
1549
|
+
let bucket = newBySet.get(set.name);
|
|
1550
|
+
if (!bucket) {
|
|
1551
|
+
bucket = {
|
|
1552
|
+
setName: set.name,
|
|
1553
|
+
modes: set.modes?.length ? [...set.modes] : ["Default"],
|
|
1554
|
+
variables: [],
|
|
1555
|
+
};
|
|
1556
|
+
newBySet.set(set.name, bucket);
|
|
1557
|
+
}
|
|
1558
|
+
bucket.variables.push(def);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
return {
|
|
1562
|
+
newCollections: [...newBySet.values()],
|
|
1563
|
+
existingCollections: [...existingById.values()],
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Execute a CreatePlan via the plugin bridge in ONE batched script (same
|
|
1568
|
+
* executeCodeViaUI transport the update phase and figma_setup_design_tokens
|
|
1569
|
+
* use — see write-tools.ts for the precedent):
|
|
1570
|
+
*
|
|
1571
|
+
* Pass 0 — create missing collections; rename the auto-created default
|
|
1572
|
+
* mode to the set's first mode, addMode() for the rest.
|
|
1573
|
+
* Pass 1 — create every variable and set its LITERAL values.
|
|
1574
|
+
* Pass 2 — set alias values, after all variables exist, so aliases among
|
|
1575
|
+
* just-created variables resolve via the in-script created-ID map.
|
|
1576
|
+
*
|
|
1577
|
+
* Per-item failures are collected and surfaced without failing the batch.
|
|
1578
|
+
*/
|
|
1579
|
+
async function applyCreates(connector, plan) {
|
|
1580
|
+
const payload = JSON.stringify(plan);
|
|
1581
|
+
const totalVars = plan.newCollections.reduce((n, c) => n + c.variables.length, 0) +
|
|
1582
|
+
plan.existingCollections.reduce((n, c) => n + c.variables.length, 0);
|
|
1583
|
+
const timeout = Math.min(60000, Math.max(15000, totalVars * 200 + plan.newCollections.length * 500));
|
|
1584
|
+
const script = `
|
|
1585
|
+
const plan = ${payload};
|
|
1586
|
+
const results = [];
|
|
1587
|
+
const aliasFailures = [];
|
|
1588
|
+
const createdIds = {};
|
|
1589
|
+
const createdCollections = [];
|
|
1590
|
+
const pendingAliases = [];
|
|
1591
|
+
|
|
1592
|
+
function createVariableWithValues(def, collection, modeMap) {
|
|
1593
|
+
try {
|
|
1594
|
+
const variable = figma.variables.createVariable(def.name, collection, def.resolvedType);
|
|
1595
|
+
if (def.description) variable.description = def.description;
|
|
1596
|
+
createdIds[def.key] = variable.id;
|
|
1597
|
+
let appliedModes = 0;
|
|
1598
|
+
const valueErrors = [];
|
|
1599
|
+
// Variable metadata (scopes / per-platform code syntax) — failures
|
|
1600
|
+
// are per-item value errors, never batch failures.
|
|
1601
|
+
if (def.scopes) {
|
|
1602
|
+
try { variable.scopes = def.scopes; }
|
|
1603
|
+
catch (err) { valueErrors.push('scopes: ' + String(err && err.message || err)); }
|
|
1604
|
+
}
|
|
1605
|
+
if (def.codeSyntax) {
|
|
1606
|
+
for (const platform in def.codeSyntax) {
|
|
1607
|
+
try { variable.setVariableCodeSyntax(platform, def.codeSyntax[platform]); }
|
|
1608
|
+
catch (err) { valueErrors.push('codeSyntax ' + platform + ': ' + String(err && err.message || err)); }
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
for (const val of def.values) {
|
|
1612
|
+
const modeId = val.modeId || (modeMap ? modeMap[val.modeName] : null);
|
|
1613
|
+
if (!modeId) { valueErrors.push('unknown mode: ' + (val.modeName || val.modeId)); continue; }
|
|
1614
|
+
if (val.kind === 'literal') {
|
|
1615
|
+
try { variable.setValueForMode(modeId, val.value); appliedModes++; }
|
|
1616
|
+
catch (err) { valueErrors.push('mode ' + modeId + ': ' + String(err && err.message || err)); }
|
|
1617
|
+
} else {
|
|
1618
|
+
// Alias values apply in pass 2, after ALL variables exist.
|
|
1619
|
+
pendingAliases.push({ variable: variable, key: def.key, modeId: modeId, targetId: val.targetId || null, targetKey: val.targetKey || null });
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
results.push({ key: def.key, name: def.name, id: variable.id, success: true, appliedModes: appliedModes, valueErrors: valueErrors });
|
|
1623
|
+
} catch (err) {
|
|
1624
|
+
results.push({ key: def.key, name: def.name, success: false, error: String(err && err.message || err) });
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// Pass 0 + 1a — new collections (full mode list), then their variables.
|
|
1629
|
+
for (const nc of plan.newCollections) {
|
|
1630
|
+
let collection;
|
|
1631
|
+
const modeMap = {};
|
|
1632
|
+
try {
|
|
1633
|
+
collection = figma.variables.createVariableCollection(nc.setName);
|
|
1634
|
+
const defaultModeId = collection.modes[0].modeId;
|
|
1635
|
+
collection.renameMode(defaultModeId, nc.modes[0]);
|
|
1636
|
+
modeMap[nc.modes[0]] = defaultModeId;
|
|
1637
|
+
for (let i = 1; i < nc.modes.length; i++) {
|
|
1638
|
+
modeMap[nc.modes[i]] = collection.addMode(nc.modes[i]);
|
|
1639
|
+
}
|
|
1640
|
+
createdCollections.push({ name: nc.setName, id: collection.id });
|
|
1641
|
+
} catch (err) {
|
|
1642
|
+
// Roll back the orphaned collection if creation succeeded but mode
|
|
1643
|
+
// setup (renameMode/addMode) threw — otherwise a half-configured
|
|
1644
|
+
// empty collection is left behind in the file.
|
|
1645
|
+
let rolledBack = false;
|
|
1646
|
+
if (collection) {
|
|
1647
|
+
try { collection.remove(); rolledBack = true; } catch (removeErr) {}
|
|
1648
|
+
}
|
|
1649
|
+
const suffix = rolledBack ? ' (partially-created collection rolled back)' : '';
|
|
1650
|
+
for (const def of nc.variables) {
|
|
1651
|
+
results.push({ key: def.key, name: def.name, success: false, error: 'collection "' + nc.setName + '" creation failed' + suffix + ': ' + String(err && err.message || err) });
|
|
1652
|
+
}
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
for (const def of nc.variables) createVariableWithValues(def, collection, modeMap);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// Pass 1b — variables missing from EXISTING collections.
|
|
1659
|
+
for (const group of plan.existingCollections) {
|
|
1660
|
+
const collection = await figma.variables.getVariableCollectionByIdAsync(group.collectionId);
|
|
1661
|
+
if (!collection) {
|
|
1662
|
+
for (const def of group.variables) {
|
|
1663
|
+
results.push({ key: def.key, name: def.name, success: false, error: 'collection not found: ' + group.collectionId });
|
|
1664
|
+
}
|
|
1665
|
+
continue;
|
|
1666
|
+
}
|
|
1667
|
+
for (const def of group.variables) createVariableWithValues(def, collection, null);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// Pass 2 — alias values. Targets are either pre-resolved IDs or keys of
|
|
1671
|
+
// variables created above (createdIds).
|
|
1672
|
+
for (const pa of pendingAliases) {
|
|
1673
|
+
try {
|
|
1674
|
+
const targetId = pa.targetId || (pa.targetKey ? createdIds[pa.targetKey] : null);
|
|
1675
|
+
if (!targetId) {
|
|
1676
|
+
aliasFailures.push({ key: pa.key, error: 'alias target was not created: ' + (pa.targetKey || 'unknown') });
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1679
|
+
pa.variable.setValueForMode(pa.modeId, { type: 'VARIABLE_ALIAS', id: targetId });
|
|
1680
|
+
} catch (err) {
|
|
1681
|
+
aliasFailures.push({ key: pa.key, error: 'alias: ' + String(err && err.message || err) });
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
return {
|
|
1686
|
+
createdCollections: createdCollections,
|
|
1687
|
+
created: results.filter(r => r.success).length,
|
|
1688
|
+
failed: results.filter(r => !r.success).length,
|
|
1689
|
+
results: results,
|
|
1690
|
+
aliasFailures: aliasFailures,
|
|
1691
|
+
createdIds: createdIds,
|
|
1692
|
+
};
|
|
1693
|
+
`;
|
|
1694
|
+
const execResult = await connector.executeCodeViaUI(script, timeout);
|
|
1695
|
+
if (!execResult?.success) {
|
|
1696
|
+
return {
|
|
1697
|
+
created: 0,
|
|
1698
|
+
createdCollections: 0,
|
|
1699
|
+
failed: totalVars,
|
|
1700
|
+
errors: [
|
|
1701
|
+
{
|
|
1702
|
+
variableId: "<create-batch>",
|
|
1703
|
+
error: execResult?.error ??
|
|
1704
|
+
"Plugin executeCodeViaUI returned an error or timed out.",
|
|
1705
|
+
},
|
|
1706
|
+
],
|
|
1707
|
+
createdIdByKey: new Map(),
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
const inner = execResult.result ?? execResult;
|
|
1711
|
+
const errors = [];
|
|
1712
|
+
const results = inner.results ?? [];
|
|
1713
|
+
const aliasFailures = inner.aliasFailures ?? [];
|
|
1714
|
+
for (const r of results) {
|
|
1715
|
+
if (!r.success)
|
|
1716
|
+
errors.push({ variableId: r.key, error: r.error });
|
|
1717
|
+
}
|
|
1718
|
+
for (const f of aliasFailures) {
|
|
1719
|
+
errors.push({ variableId: f.key, error: f.error });
|
|
1720
|
+
}
|
|
1721
|
+
// Count each variable exactly ONCE: a variable that was created but whose
|
|
1722
|
+
// alias pass failed counts as failed, not as created+failed (the previous
|
|
1723
|
+
// arithmetic double-counted it across both buckets). Alias failures can
|
|
1724
|
+
// repeat per mode — dedupe by variable key.
|
|
1725
|
+
const aliasFailedKeys = new Set(aliasFailures.map((f) => f.key));
|
|
1726
|
+
const createFailed = results.filter((r) => !r.success).length;
|
|
1727
|
+
const createdOk = results.filter((r) => r.success && !aliasFailedKeys.has(r.key)).length;
|
|
1728
|
+
return {
|
|
1729
|
+
created: createdOk,
|
|
1730
|
+
createdCollections: (inner.createdCollections ?? []).length,
|
|
1731
|
+
failed: createFailed + aliasFailedKeys.size,
|
|
1732
|
+
errors,
|
|
1733
|
+
// Alias-failed variables DO exist — keep their IDs resolvable for the
|
|
1734
|
+
// later update phase.
|
|
1735
|
+
createdIdByKey: new Map(Object.entries(inner.createdIds ?? {})),
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
// ============================================================================
|
|
1739
|
+
// DELETE PHASE — strategy "replace" only
|
|
1740
|
+
// ============================================================================
|
|
1741
|
+
/**
|
|
1742
|
+
* Delete Figma variables that are absent from the token file. STRICTLY
|
|
1743
|
+
* gated by the caller behind strategy "replace" — merge never reaches this.
|
|
1744
|
+
* Uses the connector's DELETE_VARIABLE bridge command (the same one behind
|
|
1745
|
+
* figma_delete_variable), one call per variable, with per-item error
|
|
1746
|
+
* isolation.
|
|
1747
|
+
*/
|
|
1748
|
+
async function applyDeletes(connector, toDelete, figmaDoc) {
|
|
1749
|
+
// Diff key → live Figma variable ID.
|
|
1750
|
+
const figmaIdByKey = new Map();
|
|
1751
|
+
for (const set of figmaDoc.sets) {
|
|
1752
|
+
for (const t of set.tokens) {
|
|
1753
|
+
const id = t.extensions?.["figma-console-mcp"]?.variableId;
|
|
1754
|
+
if (typeof id === "string") {
|
|
1755
|
+
figmaIdByKey.set(`${set.name}::${t.path.join(".")}`, id);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
let deleted = 0;
|
|
1760
|
+
let failed = 0;
|
|
1761
|
+
const errors = [];
|
|
1762
|
+
for (const entry of toDelete) {
|
|
1763
|
+
const variableId = figmaIdByKey.get(entry.path);
|
|
1764
|
+
if (!variableId) {
|
|
1765
|
+
failed++;
|
|
1766
|
+
errors.push({
|
|
1767
|
+
variableId: entry.path,
|
|
1768
|
+
error: "no Figma variable ID recorded for this token — cannot delete",
|
|
1769
|
+
});
|
|
1770
|
+
continue;
|
|
1771
|
+
}
|
|
1772
|
+
try {
|
|
1773
|
+
const result = await connector.deleteVariable(variableId);
|
|
1774
|
+
if (result && result.success === false) {
|
|
1775
|
+
throw new Error(result.error ?? "delete failed");
|
|
1776
|
+
}
|
|
1777
|
+
deleted++;
|
|
1778
|
+
}
|
|
1779
|
+
catch (err) {
|
|
1780
|
+
failed++;
|
|
1781
|
+
errors.push({
|
|
1782
|
+
variableId,
|
|
1783
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
return { deleted, failed, errors };
|
|
1788
|
+
}
|