@reframejs/unframer 4.2.0-reframe.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.
Files changed (111) hide show
  1. package/README.md +389 -0
  2. package/bin.js +5 -0
  3. package/dist/babel-jsx.d.ts +24 -0
  4. package/dist/babel-jsx.js +299 -0
  5. package/dist/babel-plugin-imports.d.ts +15 -0
  6. package/dist/babel-plugin-imports.js +211 -0
  7. package/dist/babel-typedoc.d.ts +39 -0
  8. package/dist/babel-typedoc.js +76 -0
  9. package/dist/bin.d.ts +2 -0
  10. package/dist/bin.js +3 -0
  11. package/dist/cli.d.ts +59 -0
  12. package/dist/cli.js +821 -0
  13. package/dist/compat.d.ts +2 -0
  14. package/dist/compat.js +23 -0
  15. package/dist/config.d.ts +18 -0
  16. package/dist/config.js +27 -0
  17. package/dist/css-core.d.ts +50 -0
  18. package/dist/css-core.js +231 -0
  19. package/dist/css.d.ts +15 -0
  20. package/dist/css.js +36 -0
  21. package/dist/esbuild.d.ts +31 -0
  22. package/dist/esbuild.js +317 -0
  23. package/dist/exporter.d.ts +96 -0
  24. package/dist/exporter.js +1151 -0
  25. package/dist/flat-cache-interceptor.d.ts +24 -0
  26. package/dist/flat-cache-interceptor.js +87 -0
  27. package/dist/framer-chunks/chunk-IKQSD2QC.js +10 -0
  28. package/dist/framer-chunks/fontshare-PZLWRK4B-MHMZIGTX.js +7 -0
  29. package/dist/framer-chunks/fontshare-SXU5BGFE-OWTMMPGS.js +7 -0
  30. package/dist/framer-chunks/fontshare-TIA7QUPT-PUDLUTQ7.js +7 -0
  31. package/dist/framer-chunks/framer-font-RD2SUPQH-Q4MS7WS6.js +7 -0
  32. package/dist/framer-chunks/google-EGNT223R-P4DUHBW2.js +7 -0
  33. package/dist/framer-chunks/google-H6SFY4F5-5JSJCGDR.js +7 -0
  34. package/dist/framer-chunks/google-YSYBFRE6-L7YAHH7V.js +7 -0
  35. package/dist/framer-client.server.d.ts +4 -0
  36. package/dist/framer-client.server.js +45 -0
  37. package/dist/framer-package-versions.d.ts +781 -0
  38. package/dist/framer-package-versions.js +782 -0
  39. package/dist/framer-types.d.ts +30 -0
  40. package/dist/framer-types.js +35 -0
  41. package/dist/framer.d.ts +4990 -0
  42. package/dist/framer.js +64296 -0
  43. package/dist/index.d.ts +10 -0
  44. package/dist/index.js +13 -0
  45. package/dist/package-manager.d.ts +10 -0
  46. package/dist/package-manager.js +138 -0
  47. package/dist/plugin-mcp-dist/lib/client-websocket.d.ts +5 -0
  48. package/dist/plugin-mcp-dist/lib/client-websocket.js +103 -0
  49. package/dist/plugin-mcp-dist/lib/cms.d.ts +10 -0
  50. package/dist/plugin-mcp-dist/lib/cms.js +58 -0
  51. package/dist/plugin-mcp-dist/lib/errors.d.ts +5 -0
  52. package/dist/plugin-mcp-dist/lib/errors.js +48 -0
  53. package/dist/plugin-mcp-dist/lib/framer-client.d.ts +2 -0
  54. package/dist/plugin-mcp-dist/lib/framer-client.js +4 -0
  55. package/dist/plugin-mcp-dist/lib/framer-client.server.d.ts +3 -0
  56. package/dist/plugin-mcp-dist/lib/framer-client.server.js +46 -0
  57. package/dist/plugin-mcp-dist/lib/framer.d.ts +30 -0
  58. package/dist/plugin-mcp-dist/lib/framer.js +1084 -0
  59. package/dist/plugin-mcp-dist/lib/hooks.d.ts +6 -0
  60. package/dist/plugin-mcp-dist/lib/hooks.js +46 -0
  61. package/dist/plugin-mcp-dist/lib/mcp-client.d.ts +142 -0
  62. package/dist/plugin-mcp-dist/lib/mcp-client.js +40 -0
  63. package/dist/plugin-mcp-dist/lib/mcp-handlers.d.ts +385 -0
  64. package/dist/plugin-mcp-dist/lib/mcp-handlers.js +1924 -0
  65. package/dist/plugin-mcp-dist/lib/mcp-tools.d.ts +5 -0
  66. package/dist/plugin-mcp-dist/lib/mcp-tools.js +5 -0
  67. package/dist/plugin-mcp-dist/lib/mcp-websocket.d.ts +10 -0
  68. package/dist/plugin-mcp-dist/lib/mcp-websocket.js +88 -0
  69. package/dist/plugin-mcp-dist/lib/plugin-websocket.d.ts +5 -0
  70. package/dist/plugin-mcp-dist/lib/plugin-websocket.js +170 -0
  71. package/dist/plugin-mcp-dist/lib/react-export.d.ts +51 -0
  72. package/dist/plugin-mcp-dist/lib/react-export.js +358 -0
  73. package/dist/plugin-mcp-dist/lib/schema.d.ts +276 -0
  74. package/dist/plugin-mcp-dist/lib/schema.js +907 -0
  75. package/dist/plugin-mcp-dist/lib/store.d.ts +2 -0
  76. package/dist/plugin-mcp-dist/lib/store.js +10 -0
  77. package/dist/plugin-mcp-dist/lib/tree-utils.d.ts +6 -0
  78. package/dist/plugin-mcp-dist/lib/tree-utils.js +141 -0
  79. package/dist/plugin-mcp-dist/lib/tunnel.d.ts +46 -0
  80. package/dist/plugin-mcp-dist/lib/tunnel.js +117 -0
  81. package/dist/plugin-mcp-dist/lib/types.d.ts +110 -0
  82. package/dist/plugin-mcp-dist/lib/types.js +188 -0
  83. package/dist/plugin-mcp-dist/lib/upstream-socket.d.ts +13 -0
  84. package/dist/plugin-mcp-dist/lib/upstream-socket.js +56 -0
  85. package/dist/plugin-mcp-dist/lib/utils.d.ts +13 -0
  86. package/dist/plugin-mcp-dist/lib/utils.js +57 -0
  87. package/dist/plugin-mcp-dist/lib/websocket-server.d.ts +10 -0
  88. package/dist/plugin-mcp-dist/lib/websocket-server.js +88 -0
  89. package/dist/plugin-mcp-dist/lib/xml.d.ts +10 -0
  90. package/dist/plugin-mcp-dist/lib/xml.js +395 -0
  91. package/dist/react.d.ts +76 -0
  92. package/dist/react.js +280 -0
  93. package/dist/renamer.d.ts +11 -0
  94. package/dist/renamer.js +139 -0
  95. package/dist/sentry.d.ts +2 -0
  96. package/dist/sentry.js +5 -0
  97. package/dist/stackblitz.d.ts +9 -0
  98. package/dist/stackblitz.js +179 -0
  99. package/dist/styles/framer.css +920 -0
  100. package/dist/styles/reset.css +37 -0
  101. package/dist/typescript.d.ts +24 -0
  102. package/dist/typescript.js +197 -0
  103. package/dist/undici-dispatcher.d.ts +2 -0
  104. package/dist/undici-dispatcher.js +10 -0
  105. package/dist/unframer-loader.d.ts +2 -0
  106. package/dist/unframer-loader.js +12 -0
  107. package/dist/utils.d.ts +20 -0
  108. package/dist/utils.js +81 -0
  109. package/dist/version.d.ts +2 -0
  110. package/dist/version.js +2 -0
  111. package/package.json +131 -0
@@ -0,0 +1,1924 @@
1
+ // MCP Tool Handlers
2
+ //
3
+ // This file contains all MCP tool handler logic extracted from App.tsx.
4
+ // Moved here to allow sharing between browser plugin and server-api modes.
5
+ import { framer, isTextNode, isComponentNode, isColorStyle, isImageAsset, isFileAsset, } from '#framer-client';
6
+ import dedent from 'string-dedent';
7
+ import { createPatch } from 'diff';
8
+ import { createSpiceflowFetch } from 'spiceflow/client';
9
+ import { framerLayersTreeToXml, extractObjectsFromXmlContent, TEMP_NODE_ID_PREFIX, } from './xml.js';
10
+ import { getFramerTree, applyAttributes, getComponentPropertyControls } from './framer.js';
11
+ import { processReactExportData } from './react-export.js';
12
+ import { propControlsToTypedocComments, componentCamelCase } from 'unframer';
13
+ import { codeComponentsResourceUri, mcpTools } from './schema.js';
14
+ export { mcpTools };
15
+ const PUBLIC_URL = process.env.PUBLIC_URL || 'https://unframer.co';
16
+ const MCP_SESSION_ID_STORAGE_KEY = 'framer-mcp-session-id';
17
+ const pluginApiClient = createSpiceflowFetch(PUBLIC_URL, {
18
+ async onRequest() {
19
+ const sessionKey = typeof localStorage === 'undefined'
20
+ ? ''
21
+ : localStorage.getItem(MCP_SESSION_ID_STORAGE_KEY) || '';
22
+ return {
23
+ headers: {
24
+ sessionKey,
25
+ },
26
+ };
27
+ },
28
+ });
29
+ // Field type documentation for CMS collections
30
+ const CMS_FIELD_TYPE_COMMENTS = {
31
+ string: 'JSON string - Plain text value (e.g., "Hello World")',
32
+ number: 'JSON number - Numeric value without quotes (e.g., 42 or 3.14)',
33
+ boolean: 'JSON boolean - true or false without quotes',
34
+ color: 'JSON string - Hex color (e.g., "#FF0000") or color style path (e.g., "/Primary")',
35
+ date: 'JSON string - ISO 8601 date (e.g., "2025-08-20T10:00:00.000Z")',
36
+ image: 'JSON string or null - Image URL (e.g., "https://example.com/image.jpg")',
37
+ link: 'JSON string or null - URL (e.g., "https://example.com" or "/page-path")',
38
+ formattedText: 'JSON string - Markdown or HTML. If you omit contentType, Markdown is assumed unless the value looks like HTML (starts with <).',
39
+ file: 'JSON string or null - File URL (e.g., "https://example.com/file.pdf")',
40
+ enum: 'JSON string - One of the predefined enum case IDs',
41
+ collectionReference: 'JSON string or null - ID of an item from the referenced collection',
42
+ multiCollectionReference: 'JSON array - Array of item ID strings (e.g., ["id1", "id2"])',
43
+ array: 'JSON array - Array of objects with nested field data',
44
+ };
45
+ // Helper function to clean all field data in an object
46
+ function cleanFieldData(fieldData) {
47
+ return Object.fromEntries(Object.entries(fieldData).map(([fieldId, fieldValue]) => [
48
+ fieldId,
49
+ cleanCMSFieldValue(fieldValue),
50
+ ]));
51
+ }
52
+ function isRecord(value) {
53
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
54
+ }
55
+ function isFieldDataEntryLike(value) {
56
+ if (!isRecord(value)) {
57
+ return false;
58
+ }
59
+ const hasValue = Object.prototype.hasOwnProperty.call(value, 'value');
60
+ return typeof value.type === 'string' && hasValue;
61
+ }
62
+ export function parseIncomingFieldData(fieldData) {
63
+ if (fieldData === undefined) {
64
+ return {};
65
+ }
66
+ if (!isRecord(fieldData)) {
67
+ throw new Error('Invalid fieldData. Expected an object where each key is a field ID and each value is a field entry object.');
68
+ }
69
+ return Object.fromEntries(Object.entries(fieldData).map(([fieldId, fieldValue]) => {
70
+ if (!isFieldDataEntryLike(fieldValue)) {
71
+ throw new Error(`Invalid fieldData["${fieldId}"]. Expected an object with string "type" and a "value" key.`);
72
+ }
73
+ return [fieldId, fieldValue];
74
+ }));
75
+ }
76
+ // Helper function to normalize incoming user fieldData
77
+ // Adds contentType: 'markdown' for formattedText fields from MCP input
78
+ function normalizeIncomingFieldData(fieldData) {
79
+ return Object.fromEntries(Object.entries(fieldData).map(([fieldId, fieldValue]) => {
80
+ // If it's a formattedText field, add contentType: 'markdown'
81
+ const isFormattedTextField = isRecord(fieldValue) && fieldValue.type === 'formattedText';
82
+ if (isFormattedTextField && !fieldValue.contentType) {
83
+ const value = fieldValue.value;
84
+ const valueString = typeof value === 'string' ? value.trim() : '';
85
+ const looksLikeHtml = valueString.startsWith('<');
86
+ // Heuristic: allow passing raw HTML without being interpreted as markdown.
87
+ // If it looks like HTML, default to html; otherwise default to markdown.
88
+ return [
89
+ fieldId,
90
+ {
91
+ ...fieldValue,
92
+ contentType: looksLikeHtml ? 'html' : 'markdown',
93
+ },
94
+ ];
95
+ }
96
+ return [fieldId, fieldValue];
97
+ }));
98
+ }
99
+ // Type-safe utility function to clean field values for API compatibility
100
+ // Converts FieldDataEntry (from existing data) to FieldDataEntryInput (for API)
101
+ function cleanCMSFieldValue(fieldValue) {
102
+ // Handle field types that need special cleaning
103
+ switch (fieldValue.type) {
104
+ case 'image':
105
+ // ImageAsset -> string | null
106
+ return {
107
+ type: fieldValue.type,
108
+ value: isImageAsset(fieldValue.value)
109
+ ? fieldValue.value.url
110
+ : (fieldValue.value ?? null),
111
+ };
112
+ case 'file':
113
+ // FileAsset -> string | null
114
+ return {
115
+ type: fieldValue.type,
116
+ value: isFileAsset(fieldValue.value)
117
+ ? fieldValue.value.url
118
+ : (fieldValue.value ?? null),
119
+ };
120
+ case 'color':
121
+ // ColorStyle | string -> string | null
122
+ // If it's a ColorStyle object, extract the light value
123
+ if (isColorStyle(fieldValue.value)) {
124
+ return {
125
+ type: fieldValue.type,
126
+ value: fieldValue.value.light,
127
+ };
128
+ }
129
+ // It's already a string
130
+ return {
131
+ type: fieldValue.type,
132
+ value: fieldValue.value,
133
+ };
134
+ case 'array':
135
+ // ArrayItem[] -> ArrayItemInput[]
136
+ // Array items only support image fields
137
+ return {
138
+ type: fieldValue.type,
139
+ value: fieldValue.value.map((item) => ({
140
+ id: item.id,
141
+ fieldData: Object.fromEntries(Object.entries(item.fieldData).map(([key, imgField]) => {
142
+ const imageField = imgField;
143
+ return [
144
+ key,
145
+ {
146
+ type: 'image',
147
+ value: isImageAsset(imageField.value)
148
+ ? imageField.value.url
149
+ : null,
150
+ },
151
+ ];
152
+ })),
153
+ })),
154
+ };
155
+ case 'formattedText':
156
+ // FormattedText has valueByLocale which we can drop for input
157
+ // Don't change contentType of existing data - preserve its original format
158
+ return {
159
+ type: fieldValue.type,
160
+ value: fieldValue.value,
161
+ };
162
+ case 'string':
163
+ // String has valueByLocale which we can drop for input
164
+ return {
165
+ type: fieldValue.type,
166
+ value: fieldValue.value,
167
+ };
168
+ case 'link':
169
+ // Link has valueByLocale which we can drop for input
170
+ return {
171
+ type: fieldValue.type,
172
+ value: fieldValue.value ?? null,
173
+ };
174
+ default:
175
+ // For other field types (boolean, number, date, enum, collectionReference, multiCollectionReference)
176
+ // they should be compatible as-is
177
+ return fieldValue;
178
+ }
179
+ }
180
+ // Helper function to strip version hash from insert URLs
181
+ function stripVersionFromUrl(url) {
182
+ if (!url)
183
+ return url;
184
+ // Remove @ and everything after it
185
+ const atIndex = url.indexOf('@');
186
+ return atIndex !== -1 ? url.substring(0, atIndex) : url;
187
+ }
188
+ // Helper function to get XML for a node
189
+ async function getNodeXml(nodeId, maxCharacters = 15000) {
190
+ const node = await framer.getNode(nodeId);
191
+ if (!node) {
192
+ return null;
193
+ }
194
+ const tree = await getFramerTree({
195
+ rootNodes: [node],
196
+ recursive: false,
197
+ });
198
+ const xml = framerLayersTreeToXml(tree, {
199
+ shouldAddNodeIdAlways: true,
200
+ maxCharacters,
201
+ });
202
+ return { xml, isReplica: node.isReplica };
203
+ }
204
+ // Helper function to detect nodes added during an operation
205
+ async function getAddedNodesDuring(callback) {
206
+ // Clear selection first
207
+ await framer.setSelection([]);
208
+ // Get canvas root and its children before operation
209
+ const canvasRoot = await framer.getCanvasRoot();
210
+ const childrenBefore = await canvasRoot.getChildren();
211
+ const idsBefore = new Set(childrenBefore.map((child) => child.id));
212
+ // Execute the callback
213
+ await callback();
214
+ // Get children after operation
215
+ const childrenAfter = await canvasRoot.getChildren();
216
+ // Find new nodes (those that weren't in the before set)
217
+ const newNodes = childrenAfter.filter((child) => !idsBefore.has(child.id));
218
+ return newNodes;
219
+ }
220
+ // Helper function to create a new Framer node based on its type
221
+ async function createFramerNode({ extractedNode, parentId, }) {
222
+ const { nodeType, attributes, newContent } = extractedNode;
223
+ switch (nodeType) {
224
+ case 'Frame': {
225
+ const newFrame = await framer.createFrameNode(attributes, parentId);
226
+ return newFrame ? { id: newFrame.id, type: 'Frame' } : null;
227
+ }
228
+ case 'Text': {
229
+ // Text nodes need special handling
230
+ const text = newContent || '';
231
+ const newNodes = await getAddedNodesDuring(async () => {
232
+ await framer.addText(text, { tag: 'p' });
233
+ });
234
+ const newNodeId = newNodes[0]?.id;
235
+ if (newNodeId) {
236
+ // Move to correct parent
237
+ await framer.setParent(newNodeId, parentId);
238
+ // Apply attributes if any
239
+ if (Object.keys(attributes).length > 0) {
240
+ const node = await framer.getNode(newNodeId);
241
+ await applyAttributes(node, attributes);
242
+ }
243
+ return { id: newNodeId, type: 'Text' };
244
+ }
245
+ return null;
246
+ }
247
+ case 'SVG': {
248
+ const svg = attributes.svg || '<svg></svg>';
249
+ const name = attributes.name;
250
+ const newNodes = await getAddedNodesDuring(async () => {
251
+ await framer.addSVG({ svg, name });
252
+ });
253
+ const newNodeId = newNodes[0]?.id;
254
+ if (newNodeId) {
255
+ // Move to correct parent
256
+ await framer.setParent(newNodeId, parentId);
257
+ // Apply remaining attributes
258
+ const remainingAttrs = { ...attributes };
259
+ delete remainingAttrs.svg;
260
+ delete remainingAttrs.name;
261
+ if (Object.keys(remainingAttrs).length > 0) {
262
+ const node = await framer.getNode(newNodeId);
263
+ await applyAttributes(node, remainingAttrs);
264
+ }
265
+ return { id: newNodeId, type: 'SVG' };
266
+ }
267
+ return null;
268
+ }
269
+ case 'ComponentInstance': {
270
+ let insertUrl = attributes.insertUrl;
271
+ // If no insertUrl, try to find it from componentId
272
+ if (!insertUrl && attributes.componentId) {
273
+ // First try to get it as a component node
274
+ const node = await framer.getNode(attributes.componentId);
275
+ if (node && isComponentNode(node) && node.insertURL) {
276
+ insertUrl = node.insertURL;
277
+ }
278
+ // If still not found, try to get it as a code file
279
+ if (!insertUrl) {
280
+ const codeFiles = await framer.getCodeFiles();
281
+ const codeFile = codeFiles.find((f) => f.id === attributes.componentId);
282
+ if (codeFile) {
283
+ // Check if the default export is a component
284
+ const defaultExport = codeFile.exports.find((e) => e.name === 'default');
285
+ if (defaultExport &&
286
+ defaultExport.type === 'component') {
287
+ insertUrl = defaultExport.insertURL;
288
+ }
289
+ }
290
+ }
291
+ }
292
+ if (!insertUrl) {
293
+ throw new Error('Cannot create component instance without insertUrl or valid componentId attributes');
294
+ }
295
+ // Check if detached mode is requested via query parameter
296
+ const url = new URL(insertUrl, 'https://framer.com');
297
+ const isDetached = url.searchParams.get('detached') === 'true';
298
+ // Remove query parameters from insertUrl
299
+ const cleanInsertUrl = insertUrl.split('?')[0];
300
+ // Prepare attributes without insertUrl/componentId
301
+ const instanceAttributes = { ...attributes };
302
+ delete instanceAttributes.insertUrl;
303
+ delete instanceAttributes.componentId;
304
+ let nodeId;
305
+ let nodeType;
306
+ if (isDetached) {
307
+ // Use addDetachedComponentLayers for detached mode
308
+ const detachedFrame = await framer.addDetachedComponentLayers({
309
+ url: cleanInsertUrl,
310
+ layout: true,
311
+ attributes: instanceAttributes,
312
+ });
313
+ if (!detachedFrame?.id) {
314
+ throw new Error('Failed to create detached component layers');
315
+ }
316
+ nodeId = detachedFrame.id;
317
+ nodeType = 'Frame'; // addDetachedComponentLayers returns a FrameNode
318
+ }
319
+ else {
320
+ // Use addComponentInstance for linked mode
321
+ const instance = await framer.addComponentInstance({
322
+ url: cleanInsertUrl,
323
+ attributes: instanceAttributes,
324
+ });
325
+ if (!instance?.id) {
326
+ throw new Error('Failed to create component instance');
327
+ }
328
+ nodeId = instance.id;
329
+ nodeType = 'ComponentInstance';
330
+ }
331
+ // Move to correct parent
332
+ await framer.setParent(nodeId, parentId);
333
+ return { id: nodeId, type: nodeType };
334
+ }
335
+ default:
336
+ return null;
337
+ }
338
+ }
339
+ // Initialize websocket connection (will be moved to authenticated component)
340
+ let cleanup;
341
+ // Track current websocket ID to prevent duplicate connections for same user
342
+ let currentWebsocketId;
343
+ // Helper function to check permissions and return error message if not allowed
344
+ function checkPermissions(...methods) {
345
+ // Cast to the expected tuple type for isAllowedTo
346
+ const [first, ...rest] = methods;
347
+ if (!first)
348
+ return null;
349
+ if (!framer.isAllowedTo(first, ...rest)) {
350
+ const methodList = methods.length > 1
351
+ ? `Your Framer user account lacks the following permissions for this project: ${methods.join(', ')}`
352
+ : `Your Framer user account lacks the "${methods[0]}" permission for this project.`;
353
+ return `Permission denied. ${methodList}\n\nPlease ask the project owner to grant you the necessary permissions.`;
354
+ }
355
+ return null;
356
+ }
357
+ // Websocket handler function
358
+ export async function mcpToolHandler({ input, type, }) {
359
+ // Validate input against zod schema before processing.
360
+ // Without this, malformed input (e.g., wrong parameter names, missing fields)
361
+ // causes cryptic runtime errors deep in handler code — like htmlparser2 crashing
362
+ // with "Cannot read properties of undefined (reading 'length')" when the `xml`
363
+ // parameter is missing from updateXmlForNode.
364
+ const tool = mcpTools[type];
365
+ if (tool?.input) {
366
+ const result = tool.input.safeParse(input);
367
+ if (!result.success) {
368
+ const issues = result.error.issues
369
+ .map((i) => {
370
+ return `- ${i.path.join('.')}: ${i.message}`;
371
+ })
372
+ .join('\n');
373
+ return `Invalid input for tool "${type}":\n${issues}\n\nExpected parameters: ${Object.keys(tool.input.shape || {}).join(', ')}`;
374
+ }
375
+ input = result.data;
376
+ }
377
+ switch (type) {
378
+ case 'getNodeXml': {
379
+ const { nodeId } = input;
380
+ // Check if this looks like a style path
381
+ if (nodeId.startsWith('/')) {
382
+ return `Cannot use getNodeXml with style paths. Style data is displayed in 'getProjectXml' under the <ColorStyles> and <TextStyles> sections. Use that tool to view all styles.`;
383
+ }
384
+ const isCodeFile = await framer.getCodeFile(nodeId);
385
+ if (isCodeFile) {
386
+ return `Cannot use getNodeXml with code files. Use 'readCodeFile' tool instead to read code file with ID: ${nodeId}`;
387
+ }
388
+ const result = await getNodeXml(nodeId);
389
+ if (!result) {
390
+ return `Node with ID ${nodeId} not found.`;
391
+ }
392
+ let response = `Node xml:\n${result.xml}`;
393
+ if (result.isReplica) {
394
+ response = `WARNING: This is a replica node (variant). It's recommended to update the original component instead to maintain consistency. Only update a few attributes on variants. These attributes will no longer inherit the primary variant values.\n\n${response}`;
395
+ }
396
+ return response;
397
+ }
398
+ case 'getSelectedNodesXml': {
399
+ const selectedNodes = await framer.getSelection();
400
+ if (!selectedNodes || selectedNodes.length === 0) {
401
+ return 'No nodes are currently selected.';
402
+ }
403
+ const tree = await getFramerTree({
404
+ rootNodes: selectedNodes,
405
+ recursive: false,
406
+ });
407
+ const xml = framerLayersTreeToXml(tree, {
408
+ shouldAddNodeIdAlways: true,
409
+ });
410
+ // Check if any selected nodes are replicas
411
+ const replicaCount = selectedNodes.filter((node) => node.isReplica).length;
412
+ let response = `Selected nodes XML:\n${xml}`;
413
+ if (replicaCount > 0) {
414
+ const warning = replicaCount === 1
415
+ ? "WARNING: One of the selected nodes is a replica (variant). It's recommended to update the original component instead."
416
+ : `WARNING: ${replicaCount} of the selected nodes are replicas (variants). It's recommended to update the original components instead.`;
417
+ response = `${warning}\n\n${response}`;
418
+ }
419
+ return response;
420
+ }
421
+ case 'getProjectXml': {
422
+ const pages = await framer.getNodesWithType('WebPageNode');
423
+ const designPages = await framer.getNodesWithType('DesignPageNode');
424
+ const components = await framer.getNodesWithType('ComponentNode');
425
+ const codeFiles = await framer.getCodeFiles();
426
+ const colorStyles = await framer.getColorStyles();
427
+ const textStyles = await framer.getTextStyles();
428
+ // Separate code files by export type
429
+ const codeComponents = codeFiles.filter((file) => file.exports.some((exp) => exp.type === 'component'));
430
+ const codeOverrides = codeFiles.filter((file) => file.exports.some((exp) => exp.type === 'override'));
431
+ const tree = [
432
+ {
433
+ name: 'Project',
434
+ comment: 'Root node containing all pages, components, and code files in the project',
435
+ children: [
436
+ {
437
+ name: 'Pages',
438
+ comment: 'Web pages that are published to the website. Use getNodeXml with a page nodeId to see its contents',
439
+ children: pages.map((page) => ({
440
+ name: 'Page',
441
+ id: page.id,
442
+ attributes: {
443
+ nodeId: page.id,
444
+ path: page.path || '',
445
+ },
446
+ children: [],
447
+ })),
448
+ },
449
+ {
450
+ name: 'DesignPages',
451
+ comment: 'Design pages for components, prototypes, and explorations. Use createPage with type="design" to add new ones',
452
+ children: designPages.map((page) => ({
453
+ name: 'DesignPage',
454
+ id: page.id,
455
+ attributes: {
456
+ nodeId: page.id,
457
+ name: page.name || '',
458
+ },
459
+ children: [],
460
+ })),
461
+ },
462
+ {
463
+ name: 'Components',
464
+ comment: 'Reusable components. Use getNodeXml with a component nodeId to see its structure',
465
+ children: components.map((component) => ({
466
+ name: 'Component',
467
+ id: component.id,
468
+ attributes: {
469
+ nodeId: component.id,
470
+ name: component.componentName || '',
471
+ },
472
+ children: [],
473
+ })),
474
+ },
475
+ {
476
+ name: 'CodeComponents',
477
+ comment: 'Code components written in React/TypeScript. Use readCodeFile to see the code',
478
+ children: codeComponents.map((file) => {
479
+ const componentExport = file.exports.find((exp) => exp.type === 'component');
480
+ return {
481
+ name: 'CodeComponent',
482
+ id: file.id,
483
+ attributes: {
484
+ codeFileId: file.id,
485
+ path: file.path,
486
+ },
487
+ children: [],
488
+ };
489
+ }),
490
+ },
491
+ {
492
+ name: 'CodeOverrides',
493
+ comment: 'Code override files that modify component behavior. Use readCodeFile to see the code',
494
+ children: codeOverrides.map((file) => ({
495
+ name: 'CodeOverride',
496
+ id: file.id,
497
+ attributes: {
498
+ codeFileId: file.id,
499
+ path: file.path,
500
+ },
501
+ children: [],
502
+ })),
503
+ },
504
+ {
505
+ name: 'ColorStyles',
506
+ comment: 'Project color styles. Reference these in XML attributes like backgroundColor="/StylePath"',
507
+ children: colorStyles.map((style) => ({
508
+ name: 'ColorStyle',
509
+ attributes: {
510
+ path: style.path,
511
+ light: style.light,
512
+ dark: style.dark || '',
513
+ },
514
+ children: [],
515
+ })),
516
+ },
517
+ {
518
+ name: 'TextStyles',
519
+ comment: 'Project text styles. Reference these in XML attributes like inlineTextStyle="/StylePath"',
520
+ children: textStyles.map((style) => ({
521
+ name: 'TextStyle',
522
+ attributes: {
523
+ path: style.path,
524
+ font: style.font?.selector || '',
525
+ fontSize: style.fontSize || '',
526
+ lineHeight: style.lineHeight || '',
527
+ letterSpacing: style.letterSpacing || '',
528
+ paragraphSpacing: String(style.paragraphSpacing || 0),
529
+ transform: style.transform || 'none',
530
+ alignment: style.alignment || 'left',
531
+ decoration: style.decoration || 'none',
532
+ balance: String(style.balance || false),
533
+ tag: style.tag || 'p',
534
+ },
535
+ children: [],
536
+ })),
537
+ },
538
+ ],
539
+ },
540
+ ];
541
+ const xml = framerLayersTreeToXml(tree, {
542
+ shouldAddNodeIdAlways: true,
543
+ });
544
+ // Get current root node (focused page, design page, or component)
545
+ const rootNode = await framer.getCanvasRoot();
546
+ const rootNodeType = (() => {
547
+ if (rootNode.__class === 'WebPageNode')
548
+ return 'page';
549
+ if (rootNode.__class === 'DesignPageNode')
550
+ return 'design page';
551
+ return 'component';
552
+ })();
553
+ const rootNodeInfo = rootNode
554
+ ? `The currently focused ${rootNodeType} ID is: \`${rootNode.id}\`, call getNodeXml with this ID to get more specific XML of the current focused ${rootNodeType} layers.`
555
+ : 'No page, design page, or component is currently focused';
556
+ // Check if user has permission to modify nodes
557
+ const canModifyNodes = framer.isAllowedTo('Node.setAttributes');
558
+ const permissionMessage = canModifyNodes
559
+ ? ''
560
+ : '\n\nIMPORTANT! You have read-only access to this project. You cannot modify nodes, create new elements. If asked to make modifications, please inform the user that you only have read-only permissions.';
561
+ return dedent `
562
+ # Project structure:
563
+
564
+ ${xml}
565
+
566
+ ${rootNodeInfo}
567
+
568
+ When you create a ComponentInstance via updateXmlForNode, it will be inserted into this focused page or component.
569
+
570
+ If you need to create or edit a Framer code file ALWAYS read the MCP resource ${codeComponentsResourceUri} first.${permissionMessage}
571
+ `;
572
+ }
573
+ case 'updateXmlForNode': {
574
+ const { nodeId: rootNodeId, xml, zoomIntoView = true } = input;
575
+ // Check all required permissions at once
576
+ const permissionError = checkPermissions('Node.setAttributes', 'TextNode.setText', 'setParent', 'createFrameNode', 'addText', 'addSVG', 'addComponentInstance');
577
+ if (permissionError)
578
+ return permissionError;
579
+ // Check if this is a code file ID
580
+ const codeFiles = await framer.getCodeFiles();
581
+ const isCodeFile = codeFiles.some((file) => file.id === rootNodeId);
582
+ if (isCodeFile) {
583
+ return `Cannot use updateXmlForNode with code files. Use 'updateCodeFile' tool instead to modify code file with ID: ${rootNodeId}`;
584
+ }
585
+ // Check if this looks like a style path
586
+ if (rootNodeId.startsWith('/')) {
587
+ return `Node ID cannot start with a slash. It should be a valid node ID, not a color style or text path. To update styles use 'manageColorStyle' or 'manageTextStyle' tools.`;
588
+ }
589
+ // Zoom into the node before making changes if requested
590
+ if (zoomIntoView) {
591
+ try {
592
+ await framer.zoomIntoView(rootNodeId, { maxZoom: 0.9 });
593
+ }
594
+ catch (error) {
595
+ // Don't fail the entire operation if zooming fails
596
+ console.warn('Failed to zoom into view:', error);
597
+ }
598
+ }
599
+ // Get the original XML before making changes
600
+ const originalResult = await getNodeXml(rootNodeId, Infinity);
601
+ const originalXml = originalResult?.xml || '';
602
+ // Extract nodes from the provided XML with node creation enabled
603
+ const extractedNodes = extractObjectsFromXmlContent(xml, {
604
+ enableNodeCreation: true,
605
+ });
606
+ const results = [];
607
+ const nodesToReorder = [];
608
+ // Phase 0: Create new nodes (nodes with temp IDs)
609
+ const tempIdToRealId = new Map();
610
+ for (const extractedNode of extractedNodes) {
611
+ // Check if this is a new node (has temp ID)
612
+ if (extractedNode.nodeId.startsWith(TEMP_NODE_ID_PREFIX)) {
613
+ try {
614
+ // Resolve parent ID (might be a temp ID that needs mapping)
615
+ let parentId = extractedNode.parentId || rootNodeId;
616
+ if (parentId.startsWith(TEMP_NODE_ID_PREFIX)) {
617
+ parentId =
618
+ tempIdToRealId.get(parentId) || rootNodeId;
619
+ }
620
+ // Create the node based on its type
621
+ const newNode = await createFramerNode({
622
+ extractedNode,
623
+ parentId,
624
+ });
625
+ if (newNode) {
626
+ // Map temp ID to real ID
627
+ tempIdToRealId.set(extractedNode.nodeId, newNode.id);
628
+ // Update the extractedNode with real ID for later phases
629
+ const oldTempId = extractedNode.nodeId;
630
+ extractedNode.nodeId = newNode.id;
631
+ // Update parent references for other nodes
632
+ extractedNodes.forEach((node) => {
633
+ if (node.parentId === oldTempId) {
634
+ node.parentId = newNode.id;
635
+ }
636
+ // Update sibling references
637
+ if (node.beforeNodeId === oldTempId) {
638
+ node.beforeNodeId = newNode.id;
639
+ }
640
+ if (node.afterNodeId === oldTempId) {
641
+ node.afterNodeId = newNode.id;
642
+ }
643
+ });
644
+ // Check if this was a detached component
645
+ const wasDetached = extractedNode.nodeType === 'ComponentInstance' &&
646
+ extractedNode.attributes.insertUrl?.includes('?detached=true');
647
+ if (wasDetached && newNode.type === 'Frame') {
648
+ results.push(`Created detached component as Frame node ${newNode.id}. IMPORTANT: Call getNodeXml on this node or its parent to inspect the internal structure (Text, Frame, SVG nodes, etc.) that was created from the component definition.`);
649
+ }
650
+ else {
651
+ results.push(`Created ${newNode.type} node ${newNode.id}`);
652
+ }
653
+ }
654
+ }
655
+ catch (error) {
656
+ const errorMessage = error instanceof Error
657
+ ? error.message
658
+ : 'Unknown error';
659
+ // Notify user of the error
660
+ await framer.notify(`Failed to create ${extractedNode.nodeType || 'node'}: ${errorMessage}`, {
661
+ variant: 'error',
662
+ });
663
+ // Rollback all created nodes using tempIdToRealId values
664
+ for (const nodeId of tempIdToRealId.values()) {
665
+ try {
666
+ const node = await framer.getNode(nodeId);
667
+ if (node) {
668
+ await node.remove();
669
+ }
670
+ }
671
+ catch (rollbackError) {
672
+ console.error(`Failed to rollback node ${nodeId}:`, rollbackError);
673
+ }
674
+ }
675
+ // Throw error to stop execution
676
+ throw new Error(`Node creation failed: ${errorMessage}`);
677
+ }
678
+ }
679
+ }
680
+ // Phase 1: Update content, attributes, and move nodes to correct parents
681
+ for (const extractedNode of extractedNodes) {
682
+ // Skip temp nodes that weren't successfully created
683
+ if (extractedNode.nodeId.startsWith(TEMP_NODE_ID_PREFIX)) {
684
+ continue;
685
+ }
686
+ const targetNodeId = extractedNode.nodeId || rootNodeId;
687
+ try {
688
+ const node = await framer.getNode(targetNodeId);
689
+ if (!node) {
690
+ results.push(`Node with ID ${targetNodeId} not found.`);
691
+ continue;
692
+ }
693
+ // Update text content for text nodes
694
+ if (extractedNode.newContent && isTextNode(node)) {
695
+ await node.setText(extractedNode.newContent);
696
+ results.push(`Updated text for node ${targetNodeId}`);
697
+ }
698
+ // Apply attributes
699
+ if (extractedNode.attributes &&
700
+ Object.keys(extractedNode.attributes).length > 0) {
701
+ await applyAttributes(node, extractedNode.attributes);
702
+ results.push(`Updated attributes for node ${targetNodeId}`);
703
+ }
704
+ // Check if parent needs to change
705
+ if (extractedNode.parentId && node.getParent) {
706
+ const currentParent = await node.getParent();
707
+ const currentParentId = currentParent?.id;
708
+ if (currentParentId !== extractedNode.parentId) {
709
+ // Move to new parent without specifying position yet
710
+ await framer.setParent(targetNodeId, extractedNode.parentId);
711
+ results.push(`Moved node ${targetNodeId} from parent ${currentParentId || 'none'} to ${extractedNode.parentId}`);
712
+ }
713
+ // Queue for reordering if sibling info is provided
714
+ if (extractedNode.beforeNodeId ||
715
+ extractedNode.afterNodeId) {
716
+ nodesToReorder.push({
717
+ nodeId: targetNodeId,
718
+ parentId: extractedNode.parentId,
719
+ beforeNodeId: extractedNode.beforeNodeId,
720
+ afterNodeId: extractedNode.afterNodeId,
721
+ });
722
+ }
723
+ }
724
+ }
725
+ catch (error) {
726
+ results.push(`Failed to process node ${targetNodeId}: ${error instanceof Error ? error.message : 'Unknown error'}`);
727
+ }
728
+ }
729
+ // Phase 2: Reorder nodes within their parents
730
+ // This must be done in a separate pass to ensure all nodes are in their correct parents first.
731
+ // Otherwise, sibling references (beforeNodeId/afterNodeId) might not be found if they haven't
732
+ // been moved yet, and index calculations would be incorrect during the moving process.
733
+ for (const reorderInfo of nodesToReorder) {
734
+ try {
735
+ const parent = await framer.getNode(reorderInfo.parentId);
736
+ if (!parent)
737
+ continue;
738
+ const siblings = await parent.getChildren();
739
+ let targetIndex;
740
+ if (reorderInfo.beforeNodeId) {
741
+ // Place after the beforeNode
742
+ const beforeIndex = siblings.findIndex((s) => s.id === reorderInfo.beforeNodeId);
743
+ if (beforeIndex !== -1) {
744
+ targetIndex = beforeIndex + 1;
745
+ }
746
+ }
747
+ else if (reorderInfo.afterNodeId) {
748
+ // Place before the afterNode
749
+ const afterIndex = siblings.findIndex((s) => s.id === reorderInfo.afterNodeId);
750
+ if (afterIndex !== -1) {
751
+ targetIndex = afterIndex;
752
+ }
753
+ }
754
+ if (targetIndex !== undefined) {
755
+ // Get current index
756
+ const currentIndex = siblings.findIndex((s) => s.id === reorderInfo.nodeId);
757
+ // Only reorder if position needs to change
758
+ if (currentIndex !== -1 &&
759
+ currentIndex !== targetIndex) {
760
+ // When reordering within the same parent (moving a node forward), we need to adjust the target index.
761
+ // This is because setParent internally removes the node first, then inserts it.
762
+ // Example: Moving node from index 1 to index 3 in array [A, B, C, D]:
763
+ // - After removal: [A, C, D] (B is removed)
764
+ // - Original index 3 is now index 2
765
+ // - So we need to insert at index 2, not 3
766
+ // Note: This only applies when reordering within the same parent, not when moving between parents
767
+ if (currentIndex < targetIndex) {
768
+ targetIndex -= 1;
769
+ }
770
+ await framer.setParent(reorderInfo.nodeId, reorderInfo.parentId, targetIndex);
771
+ results.push(`Reordered node ${reorderInfo.nodeId} within parent ${reorderInfo.parentId} to index ${targetIndex}`);
772
+ }
773
+ }
774
+ }
775
+ catch (error) {
776
+ results.push(`Failed to reorder node ${reorderInfo.nodeId}: ${error instanceof Error ? error.message : 'Unknown error'}`);
777
+ }
778
+ }
779
+ // Get the updated XML for the primary node
780
+ const updatedResult = await getNodeXml(rootNodeId, Infinity);
781
+ const updatedXml = updatedResult?.xml || '';
782
+ // Check if there were actual changes by comparing XML
783
+ const hasChanges = originalXml.trim() !== updatedXml.trim();
784
+ if (hasChanges && updatedResult) {
785
+ const resultMessage = results.length > 0
786
+ ? `Successfully updated:\n${results.join('\n')}`
787
+ : 'Successfully updated';
788
+ // Create a diff patch showing the changes with more context
789
+ const patch = createPatch('node.xml', originalXml, updatedXml, 'Before', 'After', { context: 20 });
790
+ // Add note about disabling zoom if enabled
791
+ const zoomNote = zoomIntoView
792
+ ? '\n\nNote: Set zoomIntoView=false if you want to use Framer app while MCP is working.'
793
+ : '';
794
+ return `${resultMessage}\n\nXML Changes:\n${patch}${zoomNote}`;
795
+ }
796
+ const hasErrors = results.some((r) => r.startsWith('Failed '));
797
+ if (hasErrors) {
798
+ return `Encountered errors while updating:\n${results.join('\n')}`;
799
+ }
800
+ return 'No changes were made! Make sure you are not using made up attributes, follow the outlined attributes only.';
801
+ }
802
+ case 'zoomIntoView': {
803
+ const { nodeId } = input;
804
+ const node = await framer.getNode(nodeId);
805
+ if (!node) {
806
+ return `Node with ID ${nodeId} not found.`;
807
+ }
808
+ await framer.zoomIntoView(nodeId, { maxZoom: 0.9 });
809
+ return `Zoomed into view for node ${nodeId}`;
810
+ }
811
+ case 'manageColorStyle': {
812
+ const { type, stylePath, properties } = input;
813
+ // Check permissions based on type
814
+ const permissionError = checkPermissions(type === 'create'
815
+ ? 'createColorStyle'
816
+ : 'ColorStyle.setAttributes');
817
+ if (permissionError)
818
+ return permissionError;
819
+ if (!stylePath.startsWith('/')) {
820
+ return `Color style path must start with /. Got: ${stylePath}`;
821
+ }
822
+ // Get all color styles and check if it exists
823
+ const colorStyles = await framer.getColorStyles();
824
+ const existingStyle = colorStyles.find((style) => style.path === stylePath);
825
+ if (type === 'create') {
826
+ if (existingStyle) {
827
+ return `Color style with path ${stylePath} already exists. Use type: "update" to modify it.`;
828
+ }
829
+ // Validate required fields for create
830
+ if (!properties.light) {
831
+ return `Light color is required when creating a new color style.`;
832
+ }
833
+ // Filter out name property as Framer derives it from the path
834
+ const { name, ...propertiesWithoutName } = properties;
835
+ const attributes = {
836
+ ...propertiesWithoutName,
837
+ path: stylePath,
838
+ };
839
+ try {
840
+ const result = await framer.createColorStyle(attributes);
841
+ if (!result) {
842
+ return `Failed to create color style at ${stylePath}.`;
843
+ }
844
+ return {
845
+ message: `Successfully created color style: ${result.name}`,
846
+ style: {
847
+ path: result.path,
848
+ name: result.name,
849
+ light: result.light,
850
+ dark: result.dark,
851
+ },
852
+ };
853
+ }
854
+ catch (error) {
855
+ return `Failed to create color style: ${error instanceof Error ? error.message : 'Unknown error'}`;
856
+ }
857
+ }
858
+ else {
859
+ // type === 'update'
860
+ if (!existingStyle) {
861
+ return `Color style with path ${stylePath} not found. Use type: "create" to make a new style.`;
862
+ }
863
+ const result = await existingStyle.setAttributes(properties);
864
+ if (!result) {
865
+ return `Failed to update color style ${stylePath}.`;
866
+ }
867
+ return {
868
+ message: `Successfully updated color style: ${result.name}`,
869
+ style: {
870
+ path: result.path,
871
+ light: result.light,
872
+ dark: result.dark,
873
+ },
874
+ };
875
+ }
876
+ }
877
+ case 'manageTextStyle': {
878
+ const { type, stylePath, properties } = input;
879
+ // Check permissions based on type
880
+ const permissionError = checkPermissions(type === 'create'
881
+ ? 'createTextStyle'
882
+ : 'TextStyle.setAttributes');
883
+ if (permissionError)
884
+ return permissionError;
885
+ if (!stylePath.startsWith('/')) {
886
+ return `Text style path must start with /. Got: ${stylePath}`;
887
+ }
888
+ // Get all text styles and check if it exists
889
+ const textStyles = await framer.getTextStyles();
890
+ const existingStyle = textStyles.find((style) => style.path === stylePath);
891
+ // Get fonts if font property is provided
892
+ const fonts = properties.font ? await framer.getFonts() : [];
893
+ // Get color styles once if needed
894
+ const needsColorStyles = (typeof properties.color === 'string' &&
895
+ properties.color.startsWith('/')) ||
896
+ (typeof properties.decorationColor === 'string' &&
897
+ properties.decorationColor.startsWith('/'));
898
+ const colorStyles = needsColorStyles
899
+ ? await framer.getColorStyles()
900
+ : [];
901
+ // Helper function to process attributes
902
+ const processAttributes = (attrs) => {
903
+ const processed = { ...attrs };
904
+ // Convert font selector string to Font instance
905
+ if (attrs.font && typeof attrs.font === 'string') {
906
+ const font = fonts.find((f) => f.selector === attrs.font);
907
+ if (!font) {
908
+ throw new Error(`Font with selector "${attrs.font}" not found. Use searchFonts tool to find available fonts.`);
909
+ }
910
+ processed.font = font;
911
+ }
912
+ // Handle color style paths for color field
913
+ if (typeof attrs.color === 'string' &&
914
+ attrs.color.startsWith('/')) {
915
+ const colorStyle = colorStyles.find((style) => style.path === attrs.color);
916
+ if (!colorStyle) {
917
+ throw new Error(`Color style with path ${attrs.color} not found.`);
918
+ }
919
+ processed.color = colorStyle;
920
+ }
921
+ // Handle color style paths for decorationColor field
922
+ if (typeof attrs.decorationColor === 'string' &&
923
+ attrs.decorationColor.startsWith('/')) {
924
+ const colorStyle = colorStyles.find((style) => style.path === attrs.decorationColor);
925
+ if (!colorStyle) {
926
+ throw new Error(`Color style with path ${attrs.decorationColor} not found.`);
927
+ }
928
+ processed.decorationColor = colorStyle; // TODO weirdly decorationColor needs ColorStyleData but ColorStyleData is not something frame API exposes
929
+ }
930
+ return processed;
931
+ };
932
+ if (type === 'create') {
933
+ if (existingStyle) {
934
+ return `Text style with path ${stylePath} already exists. Use type: "update" to modify it.`;
935
+ }
936
+ // Filter out name property as Framer derives it from the path
937
+ const { name, ...propertiesWithoutName } = properties;
938
+ try {
939
+ const attributes = {
940
+ ...processAttributes(propertiesWithoutName),
941
+ path: stylePath,
942
+ };
943
+ const result = await framer.createTextStyle(attributes);
944
+ if (!result) {
945
+ return `Failed to create text style at ${stylePath}.`;
946
+ }
947
+ return {
948
+ message: `Successfully created text style: ${result.name}`,
949
+ style: {
950
+ path: result.path,
951
+ name: result.name,
952
+ fontSize: result.fontSize,
953
+ lineHeight: result.lineHeight,
954
+ letterSpacing: result.letterSpacing,
955
+ paragraphSpacing: result.paragraphSpacing,
956
+ transform: result.transform,
957
+ alignment: result.alignment,
958
+ decoration: result.decoration,
959
+ balance: result.balance,
960
+ tag: result.tag,
961
+ },
962
+ };
963
+ }
964
+ catch (error) {
965
+ return `Failed to create text style: ${error instanceof Error ? error.message : 'Unknown error'}`;
966
+ }
967
+ }
968
+ else {
969
+ // type === 'update'
970
+ if (!existingStyle) {
971
+ return `Text style with path ${stylePath} not found. Use type: "create" to make a new style.`;
972
+ }
973
+ try {
974
+ const attributes = processAttributes(properties);
975
+ const result = await existingStyle.setAttributes(attributes);
976
+ if (!result) {
977
+ return `Failed to update text style ${stylePath}.`;
978
+ }
979
+ return {
980
+ message: `Successfully updated text style: ${result.name}`,
981
+ style: {
982
+ path: result.path,
983
+ name: result.name,
984
+ fontSize: result.fontSize,
985
+ lineHeight: result.lineHeight,
986
+ letterSpacing: result.letterSpacing,
987
+ paragraphSpacing: result.paragraphSpacing,
988
+ transform: result.transform,
989
+ alignment: result.alignment,
990
+ decoration: result.decoration,
991
+ balance: result.balance,
992
+ tag: result.tag,
993
+ },
994
+ };
995
+ }
996
+ catch (error) {
997
+ return `Failed to update text style: ${error instanceof Error ? error.message : 'Unknown error'}`;
998
+ }
999
+ }
1000
+ }
1001
+ case 'searchFonts': {
1002
+ const { query } = input;
1003
+ // Get all fonts from Framer
1004
+ const allFonts = await framer.getFonts();
1005
+ // Filter fonts that contain the query substring in their selector
1006
+ const matchingFonts = allFonts.filter((font) => font.selector.toLowerCase().includes(query.toLowerCase()));
1007
+ // Limit to 20 results
1008
+ const limitedFonts = matchingFonts.slice(0, 20);
1009
+ // Return formatted results
1010
+ const results = limitedFonts.map((font) => ({
1011
+ family: font.family,
1012
+ selector: font.selector,
1013
+ weight: font.weight,
1014
+ style: font.style,
1015
+ }));
1016
+ const baseMessage = matchingFonts.length > 20
1017
+ ? `Found ${matchingFonts.length} fonts matching "${query}". Showing first 20. Use a more specific search term to narrow results.`
1018
+ : `Found ${matchingFonts.length} fonts matching "${query}".`;
1019
+ const message = `${baseMessage}\n\nTo use a font: <Text font="selector">Text</Text>\nNote: font and inlineTextStyle attributes are mutually exclusive`;
1020
+ return {
1021
+ message,
1022
+ results,
1023
+ totalMatches: matchingFonts.length,
1024
+ };
1025
+ }
1026
+ case 'deleteNode': {
1027
+ const { nodeId } = input;
1028
+ // First check if this is a style path (starts with /)
1029
+ if (nodeId.startsWith('/')) {
1030
+ // Try to delete as color style first
1031
+ const colorStyles = await framer.getColorStyles();
1032
+ const colorStyle = colorStyles.find((style) => style.path === nodeId);
1033
+ if (colorStyle) {
1034
+ const permissionError = checkPermissions('ColorStyle.remove');
1035
+ if (permissionError)
1036
+ return permissionError;
1037
+ await colorStyle.remove();
1038
+ return `Successfully deleted color style ${nodeId}.`;
1039
+ }
1040
+ // Try to delete as text style
1041
+ const textStyles = await framer.getTextStyles();
1042
+ const textStyle = textStyles.find((style) => style.path === nodeId);
1043
+ if (textStyle) {
1044
+ const permissionError = checkPermissions('TextStyle.remove');
1045
+ if (permissionError)
1046
+ return permissionError;
1047
+ await textStyle.remove();
1048
+ return `Successfully deleted text style ${nodeId}.`;
1049
+ }
1050
+ return `Style with path ${nodeId} not found.`;
1051
+ }
1052
+ // Check if this is a code file ID
1053
+ const codeFiles = await framer.getCodeFiles();
1054
+ const codeFile = codeFiles.find((file) => file.id === nodeId);
1055
+ if (codeFile) {
1056
+ const permissionError = checkPermissions('CodeFile.remove');
1057
+ if (permissionError)
1058
+ return permissionError;
1059
+ await codeFile.remove();
1060
+ return `Successfully deleted code file ${nodeId}.`;
1061
+ }
1062
+ // Regular node deletion
1063
+ const permissionError = checkPermissions('Node.remove');
1064
+ if (permissionError)
1065
+ return permissionError;
1066
+ const node = await framer.getNode(nodeId);
1067
+ if (!node) {
1068
+ return `Node with ID ${nodeId} not found.`;
1069
+ }
1070
+ await node.remove();
1071
+ return `Successfully deleted node ${nodeId}.`;
1072
+ }
1073
+ case 'duplicateNode': {
1074
+ const { nodeId } = input;
1075
+ // Check permissions
1076
+ const permissionError = checkPermissions('Node.clone', 'setParent');
1077
+ if (permissionError)
1078
+ return permissionError;
1079
+ const node = await framer.getNode(nodeId);
1080
+ if (!node) {
1081
+ return `Node with ID ${nodeId} not found.`;
1082
+ }
1083
+ try {
1084
+ const parent = await node.getParent();
1085
+ if (!parent) {
1086
+ throw new Error('No parent found for node');
1087
+ }
1088
+ let cloned = await node.clone();
1089
+ if (!cloned) {
1090
+ throw new Error('No new node cloned found');
1091
+ }
1092
+ await framer.setParent(cloned.id, parent.id);
1093
+ if (!cloned) {
1094
+ return `Failed to duplicate node ${nodeId}: The operation returned null.`;
1095
+ }
1096
+ return `Here is the new node XML:\n\n` + getNodeXml(cloned.id);
1097
+ }
1098
+ catch (error) {
1099
+ return `Failed to duplicate node ${nodeId}: ${error instanceof Error ? error.message : 'Unknown error'}`;
1100
+ }
1101
+ }
1102
+ case 'exportReactComponents': {
1103
+ try {
1104
+ // Get all available components and code files
1105
+ const components = await framer.getNodesWithType('ComponentNode');
1106
+ const codeFiles = await framer.getCodeFiles();
1107
+ const selectedComponentIds = new Set();
1108
+ // Add all component nodes with insertURL
1109
+ for (const component of components) {
1110
+ if (component.id && component.insertURL) {
1111
+ selectedComponentIds.add(component.id);
1112
+ }
1113
+ }
1114
+ // Add all code files with component exports
1115
+ for (const file of codeFiles) {
1116
+ if (file.exports.some((exp) => exp.type === 'component' && exp.isDefaultExport)) {
1117
+ selectedComponentIds.add(file.id);
1118
+ }
1119
+ }
1120
+ if (selectedComponentIds.size === 0) {
1121
+ return `No components or code files found to export.`;
1122
+ }
1123
+ // Process the export data
1124
+ const data = await processReactExportData({
1125
+ selectedComponentIds,
1126
+ });
1127
+ // Get the API client and submit the export
1128
+ const responseData = await pluginApiClient('/api/plugins/reactExportPlugin/upsertProject', {
1129
+ method: 'POST',
1130
+ body: data,
1131
+ });
1132
+ if (responseData instanceof Error) {
1133
+ throw new Error(responseData.message || 'Export failed');
1134
+ }
1135
+ const projectId = responseData.projectId;
1136
+ const exportedNodeIds = Array.from(selectedComponentIds);
1137
+ return dedent `
1138
+ Components successfully exported!
1139
+
1140
+ **Exported ${JSON.stringify(exportedNodeIds)} components**
1141
+
1142
+ To create a complete example app with all your components:
1143
+
1144
+ \`npx -y unframer example-app --outDir ./example-framer-app ${projectId}\`
1145
+
1146
+ This will create a folder example-framer-app with a vite app with tailwind and the Framer components already downloaded as .jsx file and the packages already installed (node_modules already present)
1147
+
1148
+ After that you can go inside the directory with \`cd example-framer-app\` and tell the user to run the development server with npm run dev. Then visit the localhost url to see the live preview of the website.
1149
+
1150
+ If you already created the example app you can just run \`npx -y unframer --outDir src/framer ${projectId}\` inside the example app folder do download the Framer components locally as .jsx files. The example app also has a script you can run with \`npm run framer\` that does the same, without need for npx.
1151
+
1152
+ Running this command will update the .jsx files with the latest changes made in Framer. Notice that if the user adds Framer variables in the components you can customize these using React props in the code, for example the user can use a Framer variable for a link and give it the name ctaLink, then you will be able to customize this url with a React prop ctaLink. You can see available props reading the generated .jsx files first 100 lines, the available props are described with a typedoc typescript comment on a type Props at the start of the file.
1153
+
1154
+ You can run \`npx -y unframer --help\` for more available options.
1155
+
1156
+ If you install unframer locally in the project you won't need to use npx. Install it as a dependency and not a devDependency to use it in production builds. (ntice the example-app command does all of this already)
1157
+
1158
+ `;
1159
+ }
1160
+ catch (error) {
1161
+ return `Failed to export components: ${error instanceof Error ? error.message : 'Unknown error'}`;
1162
+ }
1163
+ }
1164
+ case 'createCodeFile': {
1165
+ const { name, content } = input;
1166
+ // Check permission
1167
+ const permissionError = checkPermissions('createCodeFile');
1168
+ if (permissionError)
1169
+ return permissionError;
1170
+ // Validate file name
1171
+ if (!name.endsWith('.tsx')) {
1172
+ return `Code file name must end with .tsx extension. Got: ${name}`;
1173
+ }
1174
+ try {
1175
+ const codeFile = await framer.createCodeFile(name, content);
1176
+ if (!codeFile) {
1177
+ return `Failed to create code file ${name}.`;
1178
+ }
1179
+ const componentExport = codeFile.exports.find((x) => x.type === 'component');
1180
+ // if (componentExport) {
1181
+ // await framer.addComponentInstance({
1182
+ // url: componentExport.insertURL,
1183
+ // attributes: {},
1184
+ // })
1185
+ // }
1186
+ const insertUrl = stripVersionFromUrl(componentExport?.insertURL);
1187
+ const typecheckResult = await codeFile.typecheck();
1188
+ return dedent `
1189
+ ## Successfully created code file: \`${codeFile.path}\`
1190
+
1191
+ **Code file details:**
1192
+
1193
+ - **ID:** \`${codeFile.id}\`
1194
+ - **Name:** \`${codeFile.name}\`
1195
+ - **Path:** \`${codeFile.path}\`
1196
+ - **Component Insert URL:** \`${insertUrl}\`
1197
+
1198
+ ${insertUrl ? `Use updateXmlForNode with a ComponentInstance node using insertUrl: \`${insertUrl}\` to add this component to the canvas.` : 'No component export found in this code file.'}
1199
+
1200
+ **Typecheck result:**
1201
+ \`\`\`json
1202
+ ${JSON.stringify(typecheckResult, null, 2)}
1203
+ \`\`\`
1204
+ `;
1205
+ }
1206
+ catch (error) {
1207
+ return `Failed to create code file: ${error instanceof Error ? error.message : 'Unknown error'}`;
1208
+ }
1209
+ }
1210
+ case 'readCodeFile': {
1211
+ const { codeFileId } = input;
1212
+ try {
1213
+ const codeFile = await framer.getCodeFile(codeFileId);
1214
+ if (!codeFile) {
1215
+ return `Code file with ID ${codeFileId} not found.`;
1216
+ }
1217
+ return {
1218
+ id: codeFile.id,
1219
+ name: codeFile.name,
1220
+ path: codeFile.path,
1221
+ content: codeFile.content,
1222
+ exports: codeFile.exports,
1223
+ };
1224
+ }
1225
+ catch (error) {
1226
+ return `Failed to read code file: ${error instanceof Error ? error.message : 'Unknown error'}`;
1227
+ }
1228
+ }
1229
+ case 'updateCodeFile': {
1230
+ const { codeFileId, content } = input;
1231
+ // Check permission
1232
+ const permissionError = checkPermissions('CodeFile.setFileContent');
1233
+ if (permissionError)
1234
+ return permissionError;
1235
+ try {
1236
+ const codeFile = await framer.getCodeFile(codeFileId);
1237
+ if (!codeFile) {
1238
+ return `Code file with ID ${codeFileId} not found.`;
1239
+ }
1240
+ // Update the content
1241
+ await codeFile.setFileContent(content);
1242
+ const typecheckResult = await codeFile.typecheck();
1243
+ return {
1244
+ message: `Successfully updated code file: ${codeFile.name}`,
1245
+ codeFile: {
1246
+ id: codeFile.id,
1247
+ name: codeFile.name,
1248
+ path: codeFile.path,
1249
+ exports: codeFile.exports,
1250
+ },
1251
+ typecheck: typecheckResult,
1252
+ };
1253
+ }
1254
+ catch (error) {
1255
+ return `Failed to update code file: ${error instanceof Error ? error.message : 'Unknown error'}`;
1256
+ }
1257
+ }
1258
+ case 'getComponentInsertUrlAndTypes': {
1259
+ const { id } = input;
1260
+ try {
1261
+ // Build array of component info objects
1262
+ const components = [];
1263
+ // First try as component node
1264
+ const node = await framer.getNode(id);
1265
+ if (node) {
1266
+ // Check if it's a component node
1267
+ if (!isComponentNode(node)) {
1268
+ return `Node ${id} is not a component node. This tool only works with component nodes.`;
1269
+ }
1270
+ components.push({
1271
+ name: node.name || 'Component',
1272
+ insertUrl: stripVersionFromUrl(node.insertURL || undefined),
1273
+ importName: componentCamelCase(node.componentName || node.name || 'Component'),
1274
+ });
1275
+ }
1276
+ else {
1277
+ // Try as code file
1278
+ const codeFile = await framer.getCodeFile(id);
1279
+ if (codeFile) {
1280
+ const componentExports = codeFile.exports.filter((exp) => exp.type === 'component');
1281
+ if (componentExports.length === 0) {
1282
+ return `Code file ${codeFile.name} does not export any components.`;
1283
+ }
1284
+ // Add all component exports
1285
+ for (const componentExport of componentExports) {
1286
+ components.push({
1287
+ name: componentExport.name,
1288
+ insertUrl: stripVersionFromUrl(componentExport.insertURL),
1289
+ importName: componentExport.name,
1290
+ isCodeFile: true,
1291
+ });
1292
+ }
1293
+ }
1294
+ else {
1295
+ return `ID ${id} not found. Make sure it's a valid component node ID or code file ID from getProjectXml.`;
1296
+ }
1297
+ }
1298
+ // Generate unified markdown output
1299
+ let message = '';
1300
+ // Add header based on type
1301
+ if (components[0]?.isCodeFile) {
1302
+ const codeFile = await framer.getCodeFile(id);
1303
+ message = `## Code File: ${codeFile.name}\n\n`;
1304
+ message += `This code file exports ${components.length} component(s):\n\n`;
1305
+ }
1306
+ else {
1307
+ message = `## Component: ${components[0].name}\n\n`;
1308
+ }
1309
+ // Process each component with property controls
1310
+ for (const component of components) {
1311
+ if (components.length > 1) {
1312
+ message += `### ${component.name}\n\n`;
1313
+ }
1314
+ if (!component.insertUrl) {
1315
+ message += `⚠️ No insert URL available for this component.\n\n`;
1316
+ continue;
1317
+ }
1318
+ message += `**Insert URL:** \`${component.insertUrl}\`\n\n`;
1319
+ // Get property controls and generate TypeScript documentation
1320
+ const { propertyControls } = await getComponentPropertyControls(component.insertUrl);
1321
+ // Create the import statement
1322
+ const importStatement = `import ${component.importName} from "${component.insertUrl}"`;
1323
+ message += `**Import Statement:**\n\`\`\`js\n${importStatement}\n\`\`\``;
1324
+ if (propertyControls) {
1325
+ const typedocComments = propControlsToTypedocComments({
1326
+ propertyControls,
1327
+ logger: console,
1328
+ componentImportedName: component.importName,
1329
+ });
1330
+ if (typedocComments.headerComment) {
1331
+ message += `\n\n**Props (can be used as XML attributes):**\n\`\`\`js\n${typedocComments.headerComment}\`\`\``;
1332
+ }
1333
+ }
1334
+ if (components.length > 1) {
1335
+ message += `\n\n`;
1336
+ }
1337
+ }
1338
+ // Add footer note
1339
+ message += `\n\nThese props can be used as attributes when updating ${components.length > 1 ? 'component instances' : 'the component instance'} with \`updateXmlForNode\`.`;
1340
+ return message;
1341
+ }
1342
+ catch (error) {
1343
+ return `Failed to get component insert URL and types: ${error instanceof Error ? error.message : 'Unknown error'}`;
1344
+ }
1345
+ }
1346
+ // Commented out: Use updateXmlForNode with insertUrl attribute instead, which supports all attributes in one step
1347
+ // case 'insertComponentInCanvas': {
1348
+ // const { insertUrl } = input
1349
+ //
1350
+ // // Check permission
1351
+ // const permissionError = checkPermissions('addComponentInstance')
1352
+ // if (permissionError) return permissionError
1353
+ //
1354
+ // try {
1355
+ // // Get the current root node (page or component)
1356
+ // const rootNode = await framer.getCanvasRoot()
1357
+ // if (!rootNode) {
1358
+ // return `No page or component is currently focused. Please open a page or component in Framer first.`
1359
+ // }
1360
+ //
1361
+ // // Insert the component
1362
+ // const newNode = await framer.addComponentInstance({
1363
+ // url: insertUrl,
1364
+ // attributes: {},
1365
+ // })
1366
+ //
1367
+ // if (!newNode) {
1368
+ // return `Failed to insert component with URL: ${insertUrl}`
1369
+ // }
1370
+ //
1371
+ // // Get the XML for the new node
1372
+ // const nodeXml = await getNodeXml(newNode.id)
1373
+ // if (!nodeXml) {
1374
+ // return `Component inserted but failed to get XML for node ${newNode.id}`
1375
+ // }
1376
+ //
1377
+ // return dedent`
1378
+ // ## Component Successfully Inserted
1379
+ //
1380
+ // **New Node ID:** \`${newNode.id}\`
1381
+ //
1382
+ // **Current Root:** ${rootNode.__class} \`${rootNode.id}\`
1383
+ //
1384
+ // **Component XML:**
1385
+ // \`\`\`xml
1386
+ // ${nodeXml.xml}
1387
+ // \`\`\`
1388
+ //
1389
+ // ### IMPORTANT: Component Placement Required
1390
+ //
1391
+ // The component has been inserted into the canvas but is NOT yet inside the page/component content. You MUST use \`updateXmlForNode\` to place it inside the ${rootNode.__class} structure.
1392
+ //
1393
+ // 1. First, use \`getNodeXml\` on the root node ID \`${rootNode.id}\` to see the current structure
1394
+ //
1395
+ // 2. Then use \`updateXmlForNode\` with the root node ID to add the component as a child with styling attributes:
1396
+ // \`\`\`xml
1397
+ // <${rootNode.__class} nodeId="${rootNode.id}">
1398
+ // <!-- existing children -->
1399
+ // <ComponentInstance
1400
+ // nodeId="${newNode.id}"
1401
+ // width="200px"
1402
+ // height="100px"
1403
+ // position="relative"
1404
+ // <!-- add component-specific props here -->
1405
+ // />
1406
+ // </${rootNode.__class}>
1407
+ // \`\`\`
1408
+ //
1409
+ // 3. To customize the component instance:
1410
+ // - Use \`getComponentInsertUrlAndTypes\` with the component's nodeId to see available props/attributes
1411
+ // - Add standard attributes: width, height, position, opacity, etc.
1412
+ // - Add component-specific attributes based on its property controls
1413
+ // - Example: For a Button component, you might add \`text="Click me"\` \`variant="primary"\`
1414
+ //
1415
+ // 4. The component can be placed:
1416
+ // - As a direct child of the root
1417
+ // - Inside a specific Frame or Stack
1418
+ // - At any position among siblings
1419
+ //
1420
+ // Without this placement step, the component will not be visible in the canvas.
1421
+ // `
1422
+ // } catch (error) {
1423
+ // return `Failed to insert component: ${error instanceof Error ? error.message : 'Unknown error'}`
1424
+ // }
1425
+ // }
1426
+ case 'getProjectWebsiteUrl': {
1427
+ try {
1428
+ const publishInfo = await framer.getPublishInfo();
1429
+ return publishInfo || { production: null, staging: null };
1430
+ }
1431
+ catch (error) {
1432
+ return `Failed to get project website URL: ${error instanceof Error ? error.message : 'Unknown error'}`;
1433
+ }
1434
+ }
1435
+ case 'getCMSCollections': {
1436
+ try {
1437
+ const collections = await framer.getCollections();
1438
+ // For each collection, get its field definitions
1439
+ const collectionsWithFields = await Promise.all(collections.map(async (collection) => {
1440
+ const canEdit = collection.managedBy === 'thisPlugin';
1441
+ const fields = await collection.getFields();
1442
+ return {
1443
+ id: collection.id,
1444
+ name: collection.name,
1445
+ managedBy: collection.managedBy,
1446
+ readonly: collection.readonly,
1447
+ fields: fields.map((field) => {
1448
+ const baseField = {
1449
+ id: field.id,
1450
+ name: field.name,
1451
+ type: field.type,
1452
+ };
1453
+ // Add field-specific properties if they exist
1454
+ const result = { ...baseField };
1455
+ // Add comment explaining what value type is expected
1456
+ const comment = CMS_FIELD_TYPE_COMMENTS[field.type];
1457
+ if (comment) {
1458
+ result.comment = comment;
1459
+ }
1460
+ // Common properties
1461
+ if ('required' in field)
1462
+ result.required = field.required || false;
1463
+ // FileField specific properties
1464
+ if ('allowedFileTypes' in field &&
1465
+ field.allowedFileTypes)
1466
+ result.allowedFileTypes =
1467
+ field.allowedFileTypes;
1468
+ // EnumField specific properties
1469
+ if ('cases' in field && field.cases) {
1470
+ result.cases = field.cases.map((enumCase) => ({
1471
+ id: enumCase.id,
1472
+ name: enumCase.name,
1473
+ }));
1474
+ // Update comment for enum to be more specific
1475
+ if (result.cases.length > 0) {
1476
+ const caseIds = result.cases
1477
+ .map((c) => `"${c.id}"`)
1478
+ .join(', ');
1479
+ result.comment = `JSON string - One of: ${caseIds}`;
1480
+ }
1481
+ }
1482
+ // CollectionReferenceField and MultiCollectionReferenceField specific properties
1483
+ if ('collectionId' in field) {
1484
+ result.collectionId = field.collectionId;
1485
+ // Make comment more specific for references
1486
+ const collectionName = collections.find((c) => c.id === field.collectionId)?.name;
1487
+ if (collectionName) {
1488
+ if (field.type === 'collectionReference') {
1489
+ result.comment = `JSON string or null - ID of an item from the "${collectionName}" collection`;
1490
+ }
1491
+ else if (field.type ===
1492
+ 'multiCollectionReference') {
1493
+ result.comment = `JSON array - Array of item IDs from the "${collectionName}" collection (e.g., ["id1", "id2"])`;
1494
+ }
1495
+ }
1496
+ }
1497
+ // Legacy support for generic options/defaultValue/multiline properties
1498
+ if ('options' in field && field.options)
1499
+ result.options = field.options;
1500
+ if ('defaultValue' in field &&
1501
+ field.defaultValue !== undefined)
1502
+ result.defaultValue = field.defaultValue;
1503
+ if ('multiline' in field &&
1504
+ field.multiline !== undefined)
1505
+ result.multiline = field.multiline;
1506
+ return result;
1507
+ }),
1508
+ };
1509
+ }));
1510
+ return {
1511
+ message: `Found ${collections.length} CMS collection(s)`,
1512
+ collections: collectionsWithFields,
1513
+ };
1514
+ }
1515
+ catch (error) {
1516
+ return `Failed to get CMS collections: ${error instanceof Error ? error.message : 'Unknown error'}`;
1517
+ }
1518
+ }
1519
+ case 'getCMSItems': {
1520
+ const { collectionId, skip = 0, limit = 100, filter } = input;
1521
+ const collection = await framer.getCollection(collectionId);
1522
+ if (!collection) {
1523
+ return `CMS collection with ID ${collectionId} not found`;
1524
+ }
1525
+ // Get all items from the collection
1526
+ let items = await collection.getItems();
1527
+ // Apply filtering if provided
1528
+ if (filter) {
1529
+ if (filter.query) {
1530
+ const query = filter.query.toLowerCase();
1531
+ items = items.filter((item) => {
1532
+ // Search in slug
1533
+ if (item.slug.toLowerCase().includes(query)) {
1534
+ return true;
1535
+ }
1536
+ // Search in specific field or all text fields
1537
+ if (filter.fieldName) {
1538
+ const fieldValue = item.fieldData[filter.fieldName];
1539
+ if (fieldValue) {
1540
+ const value = String(fieldValue.value).toLowerCase();
1541
+ return value.includes(query);
1542
+ }
1543
+ }
1544
+ else {
1545
+ // Search in all string/text fields
1546
+ for (const [fieldName, fieldValue,] of Object.entries(item.fieldData)) {
1547
+ if (fieldValue) {
1548
+ const value = fieldValue.value;
1549
+ if (typeof value === 'string' &&
1550
+ value.toLowerCase().includes(query)) {
1551
+ return true;
1552
+ }
1553
+ }
1554
+ }
1555
+ }
1556
+ return false;
1557
+ });
1558
+ }
1559
+ }
1560
+ // Apply pagination
1561
+ const totalItems = items.length;
1562
+ const paginatedItems = items.slice(skip, skip + limit);
1563
+ return {
1564
+ message: `Retrieved ${paginatedItems.length} of ${totalItems} item(s) from collection "${collection.name}"`,
1565
+ pagination: {
1566
+ total: totalItems,
1567
+ skip,
1568
+ limit,
1569
+ returned: paginatedItems.length,
1570
+ },
1571
+ items: paginatedItems.map((item) => ({
1572
+ id: item.id,
1573
+ slug: item.slug,
1574
+ draft: item.draft,
1575
+ fieldData: cleanFieldData(item.fieldData),
1576
+ })),
1577
+ };
1578
+ }
1579
+ case 'upsertCMSItem': {
1580
+ const { collectionId, itemId, slug, fieldData, draft = false, } = input;
1581
+ // Check permissions
1582
+ const permissionError = checkPermissions('Collection.addItems');
1583
+ if (permissionError)
1584
+ return permissionError;
1585
+ const collection = await framer.getCollection(collectionId);
1586
+ if (!collection) {
1587
+ return `CMS collection with ID ${collectionId} not found`;
1588
+ }
1589
+ // Prepare the item data
1590
+ // Normalize incoming fieldData to use markdown for formattedText fields
1591
+ const hasIncomingFieldData = fieldData !== undefined;
1592
+ const incomingFieldData = parseIncomingFieldData(fieldData);
1593
+ const normalizedFieldData = normalizeIncomingFieldData(incomingFieldData);
1594
+ const itemData = {
1595
+ draft,
1596
+ fieldData: normalizedFieldData,
1597
+ };
1598
+ if (itemId) {
1599
+ // Update existing item
1600
+ itemData.id = itemId;
1601
+ if (slug !== undefined) {
1602
+ itemData.slug = slug;
1603
+ }
1604
+ // Get the existing item to merge field data
1605
+ const existingItems = await collection.getItems();
1606
+ const existingItem = existingItems.find((item) => item.id === itemId);
1607
+ if (!existingItem) {
1608
+ return `CMS item with ID ${itemId} not found in collection ${collectionId}`;
1609
+ }
1610
+ // For updates, merge with existing field data (partial update support)
1611
+ if (hasIncomingFieldData) {
1612
+ // Clean existing field data to ensure proper format for API validation
1613
+ // Then overlay with the normalized incoming field data
1614
+ itemData.fieldData = {
1615
+ ...cleanFieldData(existingItem.fieldData),
1616
+ ...normalizedFieldData,
1617
+ };
1618
+ }
1619
+ else {
1620
+ itemData.fieldData = cleanFieldData(existingItem.fieldData);
1621
+ }
1622
+ await collection.addItems([itemData]);
1623
+ // Get the updated item to return cleaned field data
1624
+ const updatedItems = await collection.getItems();
1625
+ const updatedItem = updatedItems.find((item) => item.id === itemId);
1626
+ return {
1627
+ message: `Successfully updated CMS item "${existingItem.slug}" in collection "${collection.name}"`,
1628
+ item: {
1629
+ id: itemId,
1630
+ slug: slug || existingItem.slug,
1631
+ draft,
1632
+ fieldData: updatedItem
1633
+ ? cleanFieldData(updatedItem.fieldData)
1634
+ : itemData.fieldData,
1635
+ },
1636
+ };
1637
+ }
1638
+ else {
1639
+ // Create new item
1640
+ if (!slug) {
1641
+ return `Slug is required when creating a new CMS item`;
1642
+ }
1643
+ itemData.slug = slug;
1644
+ // Check if slug already exists
1645
+ const existingItems = await collection.getItems();
1646
+ const existingItem = existingItems.find((item) => item.slug === slug);
1647
+ if (existingItem) {
1648
+ return `CMS item with slug "${slug}" already exists in collection ${collectionId}. Use itemId to update it instead.`;
1649
+ }
1650
+ await collection.addItems([itemData]);
1651
+ // Get the newly created item to return its ID
1652
+ const updatedItems = await collection.getItems();
1653
+ const newItem = updatedItems.find((item) => item.slug === slug);
1654
+ return {
1655
+ message: `Successfully created new CMS item "${slug}" in collection "${collection.name}"`,
1656
+ item: {
1657
+ id: newItem?.id,
1658
+ slug,
1659
+ draft,
1660
+ fieldData: newItem
1661
+ ? cleanFieldData(newItem.fieldData)
1662
+ : itemData.fieldData,
1663
+ },
1664
+ };
1665
+ }
1666
+ }
1667
+ case 'deleteCMSItem': {
1668
+ const { collectionId, itemId } = input;
1669
+ // Check permission
1670
+ const permissionError = checkPermissions('Collection.removeItems');
1671
+ if (permissionError)
1672
+ return permissionError;
1673
+ const collection = await framer.getCollection(collectionId);
1674
+ if (!collection) {
1675
+ return `CMS collection with ID ${collectionId} not found`;
1676
+ }
1677
+ // Verify the item exists
1678
+ const items = await collection.getItems();
1679
+ const itemToDelete = items.find((item) => item.id === itemId);
1680
+ if (!itemToDelete) {
1681
+ return `CMS item with ID ${itemId} not found in collection ${collectionId}`;
1682
+ }
1683
+ await collection.removeItems([itemId]);
1684
+ return {
1685
+ message: `Successfully deleted CMS item "${itemToDelete.slug}" from collection "${collection.name}"`,
1686
+ deletedItem: {
1687
+ id: itemId,
1688
+ slug: itemToDelete.slug,
1689
+ },
1690
+ };
1691
+ }
1692
+ case 'createCMSCollection': {
1693
+ const { name, fields = [] } = input;
1694
+ // Check permissions for creating managed collections
1695
+ const permissionError = checkPermissions('createManagedCollection');
1696
+ if (permissionError)
1697
+ return permissionError;
1698
+ // Create the managed collection
1699
+ const collection = await framer.createManagedCollection(name);
1700
+ if (!collection) {
1701
+ return `Failed to create collection "${name}"`;
1702
+ }
1703
+ // If fields are provided, set them on the collection
1704
+ if (fields.length > 0) {
1705
+ // Convert input fields to ManagedCollectionFieldInput format
1706
+ const fieldInputs = fields.map((field) => {
1707
+ const baseField = {
1708
+ id: field.id,
1709
+ name: field.name,
1710
+ };
1711
+ // Add type-specific properties
1712
+ switch (field.type) {
1713
+ case 'file':
1714
+ return {
1715
+ ...baseField,
1716
+ type: 'file',
1717
+ allowedFileTypes: field.allowedFileTypes || [],
1718
+ required: field.required,
1719
+ };
1720
+ case 'enum':
1721
+ return {
1722
+ ...baseField,
1723
+ type: 'enum',
1724
+ cases: field.cases?.map((c) => ({
1725
+ id: c.id,
1726
+ name: c.name,
1727
+ })) || [],
1728
+ };
1729
+ case 'collectionReference':
1730
+ return {
1731
+ ...baseField,
1732
+ type: 'collectionReference',
1733
+ collectionId: field.collectionId || '',
1734
+ required: field.required,
1735
+ };
1736
+ case 'multiCollectionReference':
1737
+ return {
1738
+ ...baseField,
1739
+ type: 'multiCollectionReference',
1740
+ collectionId: field.collectionId || '',
1741
+ required: field.required,
1742
+ };
1743
+ case 'string':
1744
+ return {
1745
+ ...baseField,
1746
+ type: 'string',
1747
+ required: field.required,
1748
+ };
1749
+ case 'formattedText':
1750
+ return {
1751
+ ...baseField,
1752
+ type: 'formattedText',
1753
+ required: field.required,
1754
+ };
1755
+ case 'image':
1756
+ return {
1757
+ ...baseField,
1758
+ type: 'image',
1759
+ required: field.required,
1760
+ };
1761
+ case 'link':
1762
+ return {
1763
+ ...baseField,
1764
+ type: 'link',
1765
+ required: field.required,
1766
+ };
1767
+ case 'date':
1768
+ return {
1769
+ ...baseField,
1770
+ type: 'date',
1771
+ required: field.required,
1772
+ };
1773
+ case 'number':
1774
+ return {
1775
+ ...baseField,
1776
+ type: 'number',
1777
+ };
1778
+ case 'boolean':
1779
+ return {
1780
+ ...baseField,
1781
+ type: 'boolean',
1782
+ };
1783
+ case 'color':
1784
+ return {
1785
+ ...baseField,
1786
+ type: 'color',
1787
+ };
1788
+ default:
1789
+ return {
1790
+ ...baseField,
1791
+ type: field.type,
1792
+ };
1793
+ }
1794
+ });
1795
+ await collection.setFields(fieldInputs);
1796
+ }
1797
+ // Get the created fields to return their IDs
1798
+ const createdFields = await collection.getFields();
1799
+ return {
1800
+ message: `Successfully created CMS collection "${name}" with ${createdFields.length} field(s)`,
1801
+ collection: {
1802
+ id: collection.id,
1803
+ name: collection.name,
1804
+ managedBy: collection.managedBy,
1805
+ },
1806
+ fields: createdFields.map((f) => ({
1807
+ id: f.id,
1808
+ name: f.name,
1809
+ type: f.type,
1810
+ })),
1811
+ };
1812
+ }
1813
+ case 'createComponent': {
1814
+ const { name, nodeIds } = input;
1815
+ // Check permissions: createFrameNode maps to the same "createNode" permission
1816
+ // as createComponentNode. Also check setParent if moving nodes.
1817
+ const permissionError = nodeIds?.length
1818
+ ? checkPermissions('createFrameNode', 'setParent')
1819
+ : checkPermissions('createFrameNode');
1820
+ if (permissionError)
1821
+ return permissionError;
1822
+ try {
1823
+ const component = await framer.createComponentNode(name);
1824
+ if (!component) {
1825
+ return `Failed to create component "${name}".`;
1826
+ }
1827
+ const componentId = component.id;
1828
+ // Move existing nodes into the component if provided
1829
+ if (nodeIds && nodeIds.length > 0) {
1830
+ const moveResults = [];
1831
+ let targetIndex = 0;
1832
+ for (const nodeId of nodeIds) {
1833
+ try {
1834
+ const node = await framer.getNode(nodeId);
1835
+ if (!node) {
1836
+ moveResults.push(`Node ${nodeId} not found, skipped`);
1837
+ continue;
1838
+ }
1839
+ await framer.setParent(nodeId, componentId, targetIndex);
1840
+ targetIndex += 1;
1841
+ moveResults.push(`Moved node ${nodeId} into component`);
1842
+ }
1843
+ catch (error) {
1844
+ moveResults.push(`Failed to move node ${nodeId}: ${error instanceof Error ? error.message : 'Unknown error'}`);
1845
+ }
1846
+ }
1847
+ const xml = await getNodeXml(componentId);
1848
+ return dedent `
1849
+ Successfully created component "${name}" with ID \`${componentId}\`.
1850
+
1851
+ ${moveResults.join('\n')}
1852
+
1853
+ ${xml ? `Component XML:\n${xml.xml}` : ''}
1854
+
1855
+ Use getComponentInsertUrlAndTypes with ID \`${componentId}\` to get the insertUrl for placing instances of this component.
1856
+ `;
1857
+ }
1858
+ const xml = await getNodeXml(componentId);
1859
+ return dedent `
1860
+ Successfully created component "${name}" with ID \`${componentId}\`.
1861
+
1862
+ ${xml ? `Component XML:\n${xml.xml}` : ''}
1863
+
1864
+ Use updateXmlForNode with ID \`${componentId}\` to add content inside the component.
1865
+ Use getComponentInsertUrlAndTypes with ID \`${componentId}\` to get the insertUrl for placing instances.
1866
+ `;
1867
+ }
1868
+ catch (error) {
1869
+ return `Failed to create component: ${error instanceof Error ? error.message : 'Unknown error'}`;
1870
+ }
1871
+ }
1872
+ case 'createPage': {
1873
+ const { name, type: pageType } = input;
1874
+ if (pageType === 'design') {
1875
+ // Check permissions for creating design pages
1876
+ const permissionError = checkPermissions('createDesignPage');
1877
+ if (permissionError)
1878
+ return permissionError;
1879
+ // Create the design page
1880
+ const designPage = await framer.createDesignPage(name);
1881
+ if (!designPage) {
1882
+ return `Failed to create design page "${name}"`;
1883
+ }
1884
+ return {
1885
+ message: `Successfully created design page "${name}"`,
1886
+ page: {
1887
+ id: designPage.id,
1888
+ name: designPage.name,
1889
+ type: 'design',
1890
+ },
1891
+ hint: 'Use getNodeXml with this page ID to see its contents, or updateXmlForNode to add content to it.',
1892
+ };
1893
+ }
1894
+ else {
1895
+ // Check permissions for creating web pages
1896
+ const permissionError = checkPermissions('createWebPage');
1897
+ if (permissionError)
1898
+ return permissionError;
1899
+ // Validate path starts with /
1900
+ if (!name.startsWith('/')) {
1901
+ return `Web page path must start with "/". Got: "${name}"`;
1902
+ }
1903
+ // Create the web page
1904
+ const webPage = await framer.createWebPage(name);
1905
+ if (!webPage) {
1906
+ return `Failed to create web page "${name}"`;
1907
+ }
1908
+ return {
1909
+ message: `Successfully created web page "${name}"`,
1910
+ page: {
1911
+ id: webPage.id,
1912
+ path: webPage.path,
1913
+ type: 'web',
1914
+ },
1915
+ hint: 'Use getNodeXml with this page ID to see its contents, or updateXmlForNode to add content to it.',
1916
+ };
1917
+ }
1918
+ }
1919
+ default:
1920
+ throw new Error(`Unknown tool type: ${type}`);
1921
+ }
1922
+ }
1923
+ //# sourceMappingURL=mcp-handlers.js.map
1924
+ //# sourceMappingURL=mcp-handlers.js.map