@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.
- package/README.md +389 -0
- package/bin.js +5 -0
- package/dist/babel-jsx.d.ts +24 -0
- package/dist/babel-jsx.js +299 -0
- package/dist/babel-plugin-imports.d.ts +15 -0
- package/dist/babel-plugin-imports.js +211 -0
- package/dist/babel-typedoc.d.ts +39 -0
- package/dist/babel-typedoc.js +76 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +3 -0
- package/dist/cli.d.ts +59 -0
- package/dist/cli.js +821 -0
- package/dist/compat.d.ts +2 -0
- package/dist/compat.js +23 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +27 -0
- package/dist/css-core.d.ts +50 -0
- package/dist/css-core.js +231 -0
- package/dist/css.d.ts +15 -0
- package/dist/css.js +36 -0
- package/dist/esbuild.d.ts +31 -0
- package/dist/esbuild.js +317 -0
- package/dist/exporter.d.ts +96 -0
- package/dist/exporter.js +1151 -0
- package/dist/flat-cache-interceptor.d.ts +24 -0
- package/dist/flat-cache-interceptor.js +87 -0
- package/dist/framer-chunks/chunk-IKQSD2QC.js +10 -0
- package/dist/framer-chunks/fontshare-PZLWRK4B-MHMZIGTX.js +7 -0
- package/dist/framer-chunks/fontshare-SXU5BGFE-OWTMMPGS.js +7 -0
- package/dist/framer-chunks/fontshare-TIA7QUPT-PUDLUTQ7.js +7 -0
- package/dist/framer-chunks/framer-font-RD2SUPQH-Q4MS7WS6.js +7 -0
- package/dist/framer-chunks/google-EGNT223R-P4DUHBW2.js +7 -0
- package/dist/framer-chunks/google-H6SFY4F5-5JSJCGDR.js +7 -0
- package/dist/framer-chunks/google-YSYBFRE6-L7YAHH7V.js +7 -0
- package/dist/framer-client.server.d.ts +4 -0
- package/dist/framer-client.server.js +45 -0
- package/dist/framer-package-versions.d.ts +781 -0
- package/dist/framer-package-versions.js +782 -0
- package/dist/framer-types.d.ts +30 -0
- package/dist/framer-types.js +35 -0
- package/dist/framer.d.ts +4990 -0
- package/dist/framer.js +64296 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +13 -0
- package/dist/package-manager.d.ts +10 -0
- package/dist/package-manager.js +138 -0
- package/dist/plugin-mcp-dist/lib/client-websocket.d.ts +5 -0
- package/dist/plugin-mcp-dist/lib/client-websocket.js +103 -0
- package/dist/plugin-mcp-dist/lib/cms.d.ts +10 -0
- package/dist/plugin-mcp-dist/lib/cms.js +58 -0
- package/dist/plugin-mcp-dist/lib/errors.d.ts +5 -0
- package/dist/plugin-mcp-dist/lib/errors.js +48 -0
- package/dist/plugin-mcp-dist/lib/framer-client.d.ts +2 -0
- package/dist/plugin-mcp-dist/lib/framer-client.js +4 -0
- package/dist/plugin-mcp-dist/lib/framer-client.server.d.ts +3 -0
- package/dist/plugin-mcp-dist/lib/framer-client.server.js +46 -0
- package/dist/plugin-mcp-dist/lib/framer.d.ts +30 -0
- package/dist/plugin-mcp-dist/lib/framer.js +1084 -0
- package/dist/plugin-mcp-dist/lib/hooks.d.ts +6 -0
- package/dist/plugin-mcp-dist/lib/hooks.js +46 -0
- package/dist/plugin-mcp-dist/lib/mcp-client.d.ts +142 -0
- package/dist/plugin-mcp-dist/lib/mcp-client.js +40 -0
- package/dist/plugin-mcp-dist/lib/mcp-handlers.d.ts +385 -0
- package/dist/plugin-mcp-dist/lib/mcp-handlers.js +1924 -0
- package/dist/plugin-mcp-dist/lib/mcp-tools.d.ts +5 -0
- package/dist/plugin-mcp-dist/lib/mcp-tools.js +5 -0
- package/dist/plugin-mcp-dist/lib/mcp-websocket.d.ts +10 -0
- package/dist/plugin-mcp-dist/lib/mcp-websocket.js +88 -0
- package/dist/plugin-mcp-dist/lib/plugin-websocket.d.ts +5 -0
- package/dist/plugin-mcp-dist/lib/plugin-websocket.js +170 -0
- package/dist/plugin-mcp-dist/lib/react-export.d.ts +51 -0
- package/dist/plugin-mcp-dist/lib/react-export.js +358 -0
- package/dist/plugin-mcp-dist/lib/schema.d.ts +276 -0
- package/dist/plugin-mcp-dist/lib/schema.js +907 -0
- package/dist/plugin-mcp-dist/lib/store.d.ts +2 -0
- package/dist/plugin-mcp-dist/lib/store.js +10 -0
- package/dist/plugin-mcp-dist/lib/tree-utils.d.ts +6 -0
- package/dist/plugin-mcp-dist/lib/tree-utils.js +141 -0
- package/dist/plugin-mcp-dist/lib/tunnel.d.ts +46 -0
- package/dist/plugin-mcp-dist/lib/tunnel.js +117 -0
- package/dist/plugin-mcp-dist/lib/types.d.ts +110 -0
- package/dist/plugin-mcp-dist/lib/types.js +188 -0
- package/dist/plugin-mcp-dist/lib/upstream-socket.d.ts +13 -0
- package/dist/plugin-mcp-dist/lib/upstream-socket.js +56 -0
- package/dist/plugin-mcp-dist/lib/utils.d.ts +13 -0
- package/dist/plugin-mcp-dist/lib/utils.js +57 -0
- package/dist/plugin-mcp-dist/lib/websocket-server.d.ts +10 -0
- package/dist/plugin-mcp-dist/lib/websocket-server.js +88 -0
- package/dist/plugin-mcp-dist/lib/xml.d.ts +10 -0
- package/dist/plugin-mcp-dist/lib/xml.js +395 -0
- package/dist/react.d.ts +76 -0
- package/dist/react.js +280 -0
- package/dist/renamer.d.ts +11 -0
- package/dist/renamer.js +139 -0
- package/dist/sentry.d.ts +2 -0
- package/dist/sentry.js +5 -0
- package/dist/stackblitz.d.ts +9 -0
- package/dist/stackblitz.js +179 -0
- package/dist/styles/framer.css +920 -0
- package/dist/styles/reset.css +37 -0
- package/dist/typescript.d.ts +24 -0
- package/dist/typescript.js +197 -0
- package/dist/undici-dispatcher.d.ts +2 -0
- package/dist/undici-dispatcher.js +10 -0
- package/dist/unframer-loader.d.ts +2 -0
- package/dist/unframer-loader.js +12 -0
- package/dist/utils.d.ts +20 -0
- package/dist/utils.js +81 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- 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
|