figmatk 0.2.6 → 0.3.1
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +18 -1
- package/lib/fig-deck.mjs +4 -4
- package/lib/template-deck.mjs +643 -0
- package/mcp-server.mjs +151 -12
- package/package.json +1 -1
- package/skills/figma-slides-creator/SKILL.md +80 -7
- package/skills/figma-template-builder/SKILL.md +148 -0
package/README.md
CHANGED
|
@@ -67,7 +67,23 @@ Or add manually in Claude Desktop → Settings → Developer → Edit Config:
|
|
|
67
67
|
}
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
Available MCP tools: `figmatk_create_deck`, `figmatk_inspect`, `figmatk_list_text`, `figmatk_list_overrides`, `figmatk_update_text`, `figmatk_insert_image`, `figmatk_clone_slide`, `figmatk_remove_slide`, `figmatk_roundtrip`.
|
|
70
|
+
Available MCP tools: `figmatk_create_deck`, `figmatk_create_template_draft`, `figmatk_annotate_template_layout`, `figmatk_publish_template_draft`, `figmatk_list_template_layouts`, `figmatk_create_from_template`, `figmatk_inspect`, `figmatk_list_text`, `figmatk_list_overrides`, `figmatk_update_text`, `figmatk_insert_image`, `figmatk_clone_slide`, `figmatk_remove_slide`, `figmatk_roundtrip`.
|
|
71
|
+
|
|
72
|
+
## Template Workflows
|
|
73
|
+
|
|
74
|
+
FigmaTK supports two related template states:
|
|
75
|
+
|
|
76
|
+
- Draft templates: `SLIDE_ROW -> SLIDE -> ...`
|
|
77
|
+
- Published templates: `SLIDE_ROW -> MODULE -> SLIDE -> ...`
|
|
78
|
+
|
|
79
|
+
Use explicit naming conventions when authoring reusable templates:
|
|
80
|
+
|
|
81
|
+
- Layouts: `layout:<name>`
|
|
82
|
+
- Text slots: `slot:text:<name>`
|
|
83
|
+
- Image slots: `slot:image:<name>`
|
|
84
|
+
- Decorative fixed imagery: `fixed:image:<name>`
|
|
85
|
+
|
|
86
|
+
`figmatk_list_template_layouts` understands those conventions and only falls back to heuristic image placeholders when a layout has not been explicitly annotated yet.
|
|
71
87
|
|
|
72
88
|
## Programmatic API
|
|
73
89
|
|
|
@@ -84,6 +100,7 @@ await deck.save('output.deck');
|
|
|
84
100
|
|------|---|
|
|
85
101
|
| High-level API | [docs/figmatk-api-spec.md](docs/figmatk-api-spec.md) |
|
|
86
102
|
| Low-level FigDeck API | [docs/library.md](docs/library.md) |
|
|
103
|
+
| Template workflows | [docs/template-workflows.md](docs/template-workflows.md) |
|
|
87
104
|
| File format internals | [docs/format/](docs/format/) |
|
|
88
105
|
|
|
89
106
|
## License
|
package/lib/fig-deck.mjs
CHANGED
|
@@ -14,9 +14,10 @@ import { decompress } from 'fzstd';
|
|
|
14
14
|
import { inflateRaw, deflateRaw } from 'pako';
|
|
15
15
|
import { ZstdCodec } from 'zstd-codec';
|
|
16
16
|
import archiver from 'archiver';
|
|
17
|
-
import { readFileSync, createWriteStream, existsSync,
|
|
17
|
+
import { readFileSync, createWriteStream, existsSync, mkdtempSync } from 'fs';
|
|
18
18
|
import { execSync } from 'child_process';
|
|
19
19
|
import { join, resolve } from 'path';
|
|
20
|
+
import { tmpdir } from 'os';
|
|
20
21
|
import { nid } from './node-helpers.mjs';
|
|
21
22
|
|
|
22
23
|
export class FigDeck {
|
|
@@ -42,9 +43,8 @@ export class FigDeck {
|
|
|
42
43
|
const absPath = resolve(deckPath);
|
|
43
44
|
|
|
44
45
|
// Extract to temp dir
|
|
45
|
-
const tmp =
|
|
46
|
-
execSync(`
|
|
47
|
-
execSync(`unzip -o "${absPath}" -d ${tmp}`, { stdio: 'pipe' });
|
|
46
|
+
const tmp = mkdtempSync(join(tmpdir(), 'figmatk_'));
|
|
47
|
+
execSync(`unzip -o "${absPath}" -d "${tmp}"`, { stdio: 'pipe' });
|
|
48
48
|
deck._tempDir = tmp;
|
|
49
49
|
|
|
50
50
|
// Read canvas.fig
|
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* template-deck — Inspect, author, wrap, and instantiate Figma Slides templates.
|
|
3
|
+
*
|
|
4
|
+
* Template workflows now cover two structural states:
|
|
5
|
+
* - Draft templates: SLIDE_ROW -> SLIDE -> ...
|
|
6
|
+
* - Published templates: SLIDE_ROW -> MODULE -> SLIDE -> ...
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'crypto';
|
|
9
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'fs';
|
|
10
|
+
import { join, resolve } from 'path';
|
|
11
|
+
import { Deck } from './api.mjs';
|
|
12
|
+
import { deepClone } from './deep-clone.mjs';
|
|
13
|
+
import { FigDeck } from './fig-deck.mjs';
|
|
14
|
+
import { hexToHash } from './image-helpers.mjs';
|
|
15
|
+
import { getImageDimensions, generateThumbnail } from './image-utils.mjs';
|
|
16
|
+
import { nid, positionChar } from './node-helpers.mjs';
|
|
17
|
+
|
|
18
|
+
export const LAYOUT_PREFIX = 'layout:';
|
|
19
|
+
export const TEXT_SLOT_PREFIX = 'slot:text:';
|
|
20
|
+
export const IMAGE_SLOT_PREFIX = 'slot:image:';
|
|
21
|
+
export const FIXED_IMAGE_PREFIX = 'fixed:image:';
|
|
22
|
+
|
|
23
|
+
const INTERNAL_CANVAS_NAME = 'Internal Only Canvas';
|
|
24
|
+
const MODULE_VERSION = '1:37';
|
|
25
|
+
const DEFAULT_ROW_GAP = 2160;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Inspect a template deck and return available layouts plus explicit slot metadata.
|
|
29
|
+
*/
|
|
30
|
+
export async function listTemplateLayouts(templatePath, opts = {}) {
|
|
31
|
+
const deck = await FigDeck.fromDeckFile(templatePath);
|
|
32
|
+
return describeTemplateLayouts(deck, opts);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Create a new draft template deck from scratch.
|
|
37
|
+
*/
|
|
38
|
+
export async function createDraftTemplate(outputPath, opts = {}) {
|
|
39
|
+
const title = opts.title ?? 'Untitled';
|
|
40
|
+
const layoutNames = Array.isArray(opts.layouts) && opts.layouts.length
|
|
41
|
+
? opts.layouts
|
|
42
|
+
: ['cover'];
|
|
43
|
+
|
|
44
|
+
const deck = await Deck.create({ name: title });
|
|
45
|
+
for (const name of layoutNames) {
|
|
46
|
+
deck.addBlankSlide({ name: normalizeLayoutName(name) });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await deck.save(outputPath);
|
|
50
|
+
return statSync(resolve(outputPath)).size;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Add or update explicit layout/slot metadata on an existing draft or published template.
|
|
55
|
+
*/
|
|
56
|
+
export async function annotateTemplateLayout(path, outputPath, opts = {}) {
|
|
57
|
+
const deck = await FigDeck.fromDeckFile(path);
|
|
58
|
+
const slide = deck.getNode(opts.slideId);
|
|
59
|
+
if (!slide || slide.type !== 'SLIDE') {
|
|
60
|
+
throw new Error(`Slide not found: ${opts.slideId}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const module = getParentModule(deck, slide);
|
|
64
|
+
if (opts.layoutName) {
|
|
65
|
+
const layoutName = normalizeLayoutName(opts.layoutName);
|
|
66
|
+
slide.name = layoutName;
|
|
67
|
+
if (module) module.name = layoutName;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
renameNodes(deck, opts.textSlots, TEXT_SLOT_PREFIX);
|
|
71
|
+
renameNodes(deck, opts.imageSlots, IMAGE_SLOT_PREFIX);
|
|
72
|
+
renameNodes(deck, opts.fixedImages, FIXED_IMAGE_PREFIX);
|
|
73
|
+
|
|
74
|
+
return deck.saveDeck(outputPath);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Convert draft slides into publish-like module-backed layouts.
|
|
79
|
+
*/
|
|
80
|
+
export async function publishTemplateDraft(path, outputPath, opts = {}) {
|
|
81
|
+
const deck = await FigDeck.fromDeckFile(path);
|
|
82
|
+
const targetIds = new Set(opts.slideIds ?? []);
|
|
83
|
+
const layouts = describeTemplateLayouts(deck);
|
|
84
|
+
const draftLayouts = layouts.filter(layout => layout.state === 'draft');
|
|
85
|
+
const targets = targetIds.size
|
|
86
|
+
? draftLayouts.filter(layout => targetIds.has(layout.slideId))
|
|
87
|
+
: draftLayouts;
|
|
88
|
+
|
|
89
|
+
if (!targets.length) {
|
|
90
|
+
throw new Error('No draft template slides found to wrap');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let nextId = deck.maxLocalID() + 1;
|
|
94
|
+
for (const layout of targets) {
|
|
95
|
+
const slide = deck.getNode(layout.slideId);
|
|
96
|
+
if (!slide) continue;
|
|
97
|
+
|
|
98
|
+
const row = deck.getNode(layout.rowId);
|
|
99
|
+
if (!row) {
|
|
100
|
+
throw new Error(`Slide row not found for ${layout.slideId}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const moduleGuid = { sessionID: slide.guid.sessionID, localID: nextId++ };
|
|
104
|
+
const module = createModuleWrapper(slide, moduleGuid);
|
|
105
|
+
module.parentIndex = deepClone(slide.parentIndex);
|
|
106
|
+
|
|
107
|
+
slide.parentIndex = { guid: deepClone(moduleGuid), position: '!' };
|
|
108
|
+
|
|
109
|
+
deck.message.nodeChanges.push(module);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
deck.rebuildMaps();
|
|
113
|
+
return deck.saveDeck(outputPath);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create a new deck from a template by cherry-picking and populating layouts.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} templatePath
|
|
120
|
+
* @param {string} outputPath
|
|
121
|
+
* @param {Array<{slideId: string, text?: Record<string, string>, images?: Record<string, string>}>} slideDefs
|
|
122
|
+
*/
|
|
123
|
+
export async function createFromTemplate(templatePath, outputPath, slideDefs) {
|
|
124
|
+
const deck = await FigDeck.fromDeckFile(templatePath);
|
|
125
|
+
const layouts = describeTemplateLayouts(deck);
|
|
126
|
+
const layoutBySlideId = new Map(layouts.map(layout => [layout.slideId, layout]));
|
|
127
|
+
const mainRows = getMainSlideRows(deck);
|
|
128
|
+
const targetRow = mainRows[0];
|
|
129
|
+
|
|
130
|
+
if (!targetRow) {
|
|
131
|
+
throw new Error('No main-canvas SLIDE_ROW found in template');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let nextId = deck.maxLocalID() + 1;
|
|
135
|
+
const sessionId = 200;
|
|
136
|
+
|
|
137
|
+
for (let defIdx = 0; defIdx < slideDefs.length; defIdx++) {
|
|
138
|
+
const def = slideDefs[defIdx];
|
|
139
|
+
const sourceLayout = layoutBySlideId.get(def.slideId);
|
|
140
|
+
if (!sourceLayout) throw new Error(`Layout not found: ${def.slideId}`);
|
|
141
|
+
|
|
142
|
+
const rootId = sourceLayout.moduleId ?? sourceLayout.slideId;
|
|
143
|
+
const subtreeNodes = [];
|
|
144
|
+
deck.walkTree(rootId, node => {
|
|
145
|
+
if (node.phase !== 'REMOVED') subtreeNodes.push(node);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const idMap = new Map();
|
|
149
|
+
for (const node of subtreeNodes) {
|
|
150
|
+
idMap.set(nid(node), { sessionID: sessionId, localID: nextId++ });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const reverseIdMap = new Map();
|
|
154
|
+
for (const [oldId, guid] of idMap.entries()) {
|
|
155
|
+
reverseIdMap.set(`${guid.sessionID}:${guid.localID}`, oldId);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const clonedNodes = subtreeNodes.map(node => {
|
|
159
|
+
const clone = deepClone(node);
|
|
160
|
+
const oldId = nid(node);
|
|
161
|
+
const newGuid = idMap.get(oldId);
|
|
162
|
+
if (newGuid) clone.guid = newGuid;
|
|
163
|
+
|
|
164
|
+
if (oldId === rootId) {
|
|
165
|
+
clone.parentIndex = {
|
|
166
|
+
guid: deepClone(targetRow.guid),
|
|
167
|
+
position: positionChar(defIdx),
|
|
168
|
+
};
|
|
169
|
+
if (clone.transform) {
|
|
170
|
+
clone.transform.m02 = defIdx * DEFAULT_ROW_GAP;
|
|
171
|
+
}
|
|
172
|
+
} else if (clone.parentIndex?.guid) {
|
|
173
|
+
const parentId = `${clone.parentIndex.guid.sessionID}:${clone.parentIndex.guid.localID}`;
|
|
174
|
+
const remappedParent = idMap.get(parentId);
|
|
175
|
+
if (remappedParent) {
|
|
176
|
+
clone.parentIndex = { ...clone.parentIndex, guid: remappedParent };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
clone.phase = 'CREATED';
|
|
181
|
+
delete clone.slideThumbnailHash;
|
|
182
|
+
delete clone.editInfo;
|
|
183
|
+
delete clone.prototypeInteractions;
|
|
184
|
+
|
|
185
|
+
return clone;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
for (const clone of clonedNodes) {
|
|
189
|
+
const originalId = reverseIdMap.get(nid(clone));
|
|
190
|
+
const textValue = pickMappedValue(def.text, candidateTextKeys(sourceLayout, clone, originalId));
|
|
191
|
+
if (textValue !== undefined) {
|
|
192
|
+
applyTextValue(clone, textValue);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const imagePath = pickMappedValue(def.images, candidateImageKeys(sourceLayout, clone, originalId));
|
|
196
|
+
if (imagePath !== undefined) {
|
|
197
|
+
await applyImageValue(deck, clone, imagePath);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
deck.message.nodeChanges.push(...clonedNodes);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
deck.rebuildMaps();
|
|
205
|
+
|
|
206
|
+
const pruneIds = new Set();
|
|
207
|
+
for (const layout of layouts) {
|
|
208
|
+
collectSubtree(deck, layout.moduleId ?? layout.slideId, pruneIds);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const targetRowId = nid(targetRow);
|
|
212
|
+
const extraRowIds = new Set(mainRows.slice(1).map(row => nid(row)));
|
|
213
|
+
|
|
214
|
+
deck.message.nodeChanges = deck.message.nodeChanges.filter(node => {
|
|
215
|
+
const id = nid(node);
|
|
216
|
+
if (!id) return true;
|
|
217
|
+
if (pruneIds.has(id)) return false;
|
|
218
|
+
if (id !== targetRowId && extraRowIds.has(id)) return false;
|
|
219
|
+
return true;
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
deck.rebuildMaps();
|
|
223
|
+
return deck.saveDeck(outputPath);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function describeTemplateLayouts(deck, opts = {}) {
|
|
227
|
+
const includeInternal = Boolean(opts.includeInternal);
|
|
228
|
+
const rows = includeInternal
|
|
229
|
+
? deck.message.nodeChanges.filter(node => node.type === 'SLIDE_ROW' && node.phase !== 'REMOVED')
|
|
230
|
+
: getMainSlideRows(deck);
|
|
231
|
+
|
|
232
|
+
const layouts = [];
|
|
233
|
+
for (const row of rows) {
|
|
234
|
+
for (const layout of getRowLayouts(deck, row)) {
|
|
235
|
+
layouts.push(describeLayout(deck, layout, row));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return layouts;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function getMainSlideRows(deck) {
|
|
242
|
+
return deck.message.nodeChanges.filter(node => {
|
|
243
|
+
if (node.type !== 'SLIDE_ROW' || node.phase === 'REMOVED') return false;
|
|
244
|
+
const canvas = getAncestorCanvas(deck, node);
|
|
245
|
+
return !isInternalCanvas(canvas);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function getRowLayouts(deck, row) {
|
|
250
|
+
const layouts = [];
|
|
251
|
+
for (const child of deck.getChildren(nid(row))) {
|
|
252
|
+
if (child.phase === 'REMOVED') continue;
|
|
253
|
+
if (child.type === 'MODULE') {
|
|
254
|
+
for (const maybeSlide of deck.getChildren(nid(child))) {
|
|
255
|
+
if (maybeSlide.phase === 'REMOVED' || maybeSlide.type !== 'SLIDE') continue;
|
|
256
|
+
layouts.push({ slide: maybeSlide, module: child });
|
|
257
|
+
}
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (child.type === 'SLIDE') {
|
|
261
|
+
layouts.push({ slide: child, module: null });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return layouts;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function describeLayout(deck, layout, row) {
|
|
268
|
+
const rootId = layout.module ? nid(layout.module) : nid(layout.slide);
|
|
269
|
+
const slotDiscovery = discoverSlots(deck, rootId);
|
|
270
|
+
const nameSource = layout.module?.name || layout.slide.name || layout.slide.name || 'Untitled';
|
|
271
|
+
const canonicalName = stripLayoutPrefix(nameSource);
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
slideId: nid(layout.slide),
|
|
275
|
+
moduleId: layout.module ? nid(layout.module) : null,
|
|
276
|
+
rowId: nid(row),
|
|
277
|
+
rowName: row.name || 'Slide row',
|
|
278
|
+
name: canonicalName,
|
|
279
|
+
rawName: nameSource,
|
|
280
|
+
state: layout.module ? 'published' : 'draft',
|
|
281
|
+
hasExplicitSlotMetadata: slotDiscovery.hasExplicitSlotMetadata,
|
|
282
|
+
slots: [...slotDiscovery.textSlots, ...slotDiscovery.imageSlots],
|
|
283
|
+
textFields: slotDiscovery.textSlots.map(slot => ({
|
|
284
|
+
nodeId: slot.nodeId,
|
|
285
|
+
name: slot.name,
|
|
286
|
+
preview: slot.preview,
|
|
287
|
+
source: slot.source,
|
|
288
|
+
})),
|
|
289
|
+
imagePlaceholders: slotDiscovery.imageSlots.map(slot => ({
|
|
290
|
+
nodeId: slot.nodeId,
|
|
291
|
+
name: slot.name,
|
|
292
|
+
type: slot.nodeType,
|
|
293
|
+
width: slot.width,
|
|
294
|
+
height: slot.height,
|
|
295
|
+
hasCurrentImage: slot.hasCurrentImage,
|
|
296
|
+
source: slot.source,
|
|
297
|
+
})),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function discoverSlots(deck, rootId) {
|
|
302
|
+
const explicitTextSlots = [];
|
|
303
|
+
const explicitImageSlots = [];
|
|
304
|
+
const fallbackTextSlots = [];
|
|
305
|
+
const fallbackImageSlots = [];
|
|
306
|
+
|
|
307
|
+
deck.walkTree(rootId, node => {
|
|
308
|
+
const textSlotName = parsePrefixedName(node.name, TEXT_SLOT_PREFIX);
|
|
309
|
+
if (textSlotName) {
|
|
310
|
+
const slot = describeTextSlot(node, textSlotName, 'explicit');
|
|
311
|
+
if (slot) explicitTextSlots.push(slot);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const imageSlotName = parsePrefixedName(node.name, IMAGE_SLOT_PREFIX);
|
|
316
|
+
if (imageSlotName) {
|
|
317
|
+
const slot = describeImageSlot(node, imageSlotName, 'explicit');
|
|
318
|
+
if (slot) explicitImageSlots.push(slot);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (parsePrefixedName(node.name, FIXED_IMAGE_PREFIX)) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const fallbackText = describeFallbackTextSlot(node);
|
|
327
|
+
if (fallbackText) fallbackTextSlots.push(fallbackText);
|
|
328
|
+
|
|
329
|
+
const fallbackImage = describeFallbackImageSlot(deck, node);
|
|
330
|
+
if (fallbackImage) fallbackImageSlots.push(fallbackImage);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const hasExplicitSlotMetadata = explicitTextSlots.length > 0 || explicitImageSlots.length > 0;
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
hasExplicitSlotMetadata,
|
|
337
|
+
textSlots: explicitTextSlots.length ? explicitTextSlots : fallbackTextSlots,
|
|
338
|
+
imageSlots: hasExplicitSlotMetadata ? explicitImageSlots : fallbackImageSlots,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function describeFallbackTextSlot(node) {
|
|
343
|
+
if (node.type === 'TEXT' && node.name) {
|
|
344
|
+
return describeTextSlot(node, node.name, 'heuristic');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (node.type === 'SHAPE_WITH_TEXT' && node.nodeGenerationData?.overrides?.length) {
|
|
348
|
+
return {
|
|
349
|
+
type: 'text',
|
|
350
|
+
nodeId: nid(node),
|
|
351
|
+
name: `#${nid(node)}`,
|
|
352
|
+
preview: firstShapeText(node),
|
|
353
|
+
source: 'heuristic',
|
|
354
|
+
nodeType: node.type,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function describeTextSlot(node, name, source) {
|
|
362
|
+
if (node.type === 'TEXT') {
|
|
363
|
+
return {
|
|
364
|
+
type: 'text',
|
|
365
|
+
nodeId: nid(node),
|
|
366
|
+
name,
|
|
367
|
+
preview: (node.textData?.characters ?? '').slice(0, 80),
|
|
368
|
+
source,
|
|
369
|
+
nodeType: node.type,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (node.type === 'SHAPE_WITH_TEXT') {
|
|
374
|
+
return {
|
|
375
|
+
type: 'text',
|
|
376
|
+
nodeId: nid(node),
|
|
377
|
+
name,
|
|
378
|
+
preview: firstShapeText(node),
|
|
379
|
+
source,
|
|
380
|
+
nodeType: node.type,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function describeFallbackImageSlot(deck, node) {
|
|
388
|
+
if (node.name && parsePrefixedName(node.name, FIXED_IMAGE_PREFIX)) return null;
|
|
389
|
+
const hasImageFill = node.fillPaints?.some(fill => fill.type === 'IMAGE');
|
|
390
|
+
const isLargeEmptyFrame = (node.type === 'FRAME' || node.type === 'ROUNDED_RECTANGLE')
|
|
391
|
+
&& (node.size?.x ?? 0) > 100
|
|
392
|
+
&& (node.size?.y ?? 0) > 100
|
|
393
|
+
&& deck.getChildren(nid(node)).filter(child => child.phase !== 'REMOVED').length === 0;
|
|
394
|
+
|
|
395
|
+
if (!hasImageFill && !isLargeEmptyFrame) return null;
|
|
396
|
+
|
|
397
|
+
return describeImageSlot(node, `#${nid(node)}`, 'heuristic');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function describeImageSlot(node, name, source) {
|
|
401
|
+
const hasImageFill = node.fillPaints?.some(fill => fill.type === 'IMAGE') ?? false;
|
|
402
|
+
return {
|
|
403
|
+
type: 'image',
|
|
404
|
+
nodeId: nid(node),
|
|
405
|
+
name,
|
|
406
|
+
source,
|
|
407
|
+
nodeType: node.type,
|
|
408
|
+
width: Math.round(node.size?.x ?? 0),
|
|
409
|
+
height: Math.round(node.size?.y ?? 0),
|
|
410
|
+
hasCurrentImage: hasImageFill,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function candidateTextKeys(layout, node, originalId) {
|
|
415
|
+
const keys = [];
|
|
416
|
+
const field = layout.textFields.find(entry => entry.nodeId === originalId);
|
|
417
|
+
if (field?.name) keys.push(field.name);
|
|
418
|
+
if (originalId) {
|
|
419
|
+
keys.push(originalId);
|
|
420
|
+
keys.push(`#${originalId}`);
|
|
421
|
+
}
|
|
422
|
+
if (node.name) keys.push(node.name);
|
|
423
|
+
return dedupe(keys);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function candidateImageKeys(layout, node, originalId) {
|
|
427
|
+
const keys = [];
|
|
428
|
+
const field = layout.imagePlaceholders.find(entry => entry.nodeId === originalId);
|
|
429
|
+
if (field?.name) keys.push(field.name);
|
|
430
|
+
if (originalId) {
|
|
431
|
+
keys.push(originalId);
|
|
432
|
+
keys.push(`#${originalId}`);
|
|
433
|
+
}
|
|
434
|
+
if (node.name) keys.push(node.name);
|
|
435
|
+
return dedupe(keys);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function pickMappedValue(map, keys) {
|
|
439
|
+
if (!map) return undefined;
|
|
440
|
+
for (const key of keys) {
|
|
441
|
+
if (key in map) return map[key];
|
|
442
|
+
}
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function applyTextValue(node, text) {
|
|
447
|
+
const chars = text === '' || text == null ? ' ' : text;
|
|
448
|
+
|
|
449
|
+
if (node.type === 'TEXT') {
|
|
450
|
+
if (!node.textData) node.textData = {};
|
|
451
|
+
node.textData.characters = chars;
|
|
452
|
+
node.textData.lines = chars.split('\n').map(() => ({
|
|
453
|
+
lineType: 'PLAIN',
|
|
454
|
+
styleId: 0,
|
|
455
|
+
indentationLevel: 0,
|
|
456
|
+
sourceDirectionality: 'AUTO',
|
|
457
|
+
listStartOffset: 0,
|
|
458
|
+
isFirstLineOfList: false,
|
|
459
|
+
}));
|
|
460
|
+
delete node.derivedTextData;
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (node.type === 'SHAPE_WITH_TEXT' && node.nodeGenerationData?.overrides) {
|
|
465
|
+
for (const override of node.nodeGenerationData.overrides) {
|
|
466
|
+
if (!override.textData) continue;
|
|
467
|
+
override.textData.characters = chars;
|
|
468
|
+
override.textData.lines = chars.split('\n').map(() => ({
|
|
469
|
+
lineType: 'PLAIN',
|
|
470
|
+
styleId: 0,
|
|
471
|
+
indentationLevel: 0,
|
|
472
|
+
sourceDirectionality: 'AUTO',
|
|
473
|
+
listStartOffset: 0,
|
|
474
|
+
isFirstLineOfList: false,
|
|
475
|
+
}));
|
|
476
|
+
}
|
|
477
|
+
delete node.derivedImmutableFrameData;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function applyImageValue(deck, node, imagePath) {
|
|
482
|
+
const absPath = resolve(imagePath);
|
|
483
|
+
const imgBuf = readFileSync(absPath);
|
|
484
|
+
const imgHash = sha1Hex(imgBuf);
|
|
485
|
+
const { width, height } = await getImageDimensions(imgBuf);
|
|
486
|
+
|
|
487
|
+
const tmpThumb = `/tmp/figmatk_thumb_${Date.now()}_${Math.random().toString(36).slice(2)}.png`;
|
|
488
|
+
await generateThumbnail(imgBuf, tmpThumb);
|
|
489
|
+
const thumbHash = sha1Hex(readFileSync(tmpThumb));
|
|
490
|
+
|
|
491
|
+
copyToImagesDir(deck, imgHash, absPath);
|
|
492
|
+
copyToImagesDir(deck, thumbHash, tmpThumb);
|
|
493
|
+
|
|
494
|
+
const fill = {
|
|
495
|
+
type: 'IMAGE',
|
|
496
|
+
opacity: 1,
|
|
497
|
+
visible: true,
|
|
498
|
+
blendMode: 'NORMAL',
|
|
499
|
+
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
|
|
500
|
+
image: { hash: hexToHash(imgHash), name: imgHash },
|
|
501
|
+
imageThumbnail: { hash: hexToHash(thumbHash), name: thumbHash },
|
|
502
|
+
animationFrame: 0,
|
|
503
|
+
imageScaleMode: existingImageScaleMode(node) ?? 'FILL',
|
|
504
|
+
imageShouldColorManage: false,
|
|
505
|
+
rotation: 0,
|
|
506
|
+
scale: 0.5,
|
|
507
|
+
originalImageWidth: width,
|
|
508
|
+
originalImageHeight: height,
|
|
509
|
+
thumbHash: new Uint8Array(0),
|
|
510
|
+
altText: '',
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
if (node.fillPaints?.length) {
|
|
514
|
+
const idx = node.fillPaints.findIndex(paint => paint.type === 'IMAGE');
|
|
515
|
+
if (idx >= 0) {
|
|
516
|
+
node.fillPaints.splice(idx, 1, fill);
|
|
517
|
+
} else {
|
|
518
|
+
node.fillPaints = [fill];
|
|
519
|
+
}
|
|
520
|
+
} else {
|
|
521
|
+
node.fillPaints = [fill];
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
delete node.derivedImmutableFrameData;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function collectSubtree(deck, rootId, seen) {
|
|
528
|
+
if (!rootId || seen.has(rootId)) return;
|
|
529
|
+
seen.add(rootId);
|
|
530
|
+
for (const child of deck.getChildren(rootId)) {
|
|
531
|
+
collectSubtree(deck, nid(child), seen);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function renameNodes(deck, map, prefix) {
|
|
536
|
+
if (!map) return;
|
|
537
|
+
for (const [nodeId, name] of Object.entries(map)) {
|
|
538
|
+
const node = deck.getNode(nodeId);
|
|
539
|
+
if (!node) throw new Error(`Node not found: ${nodeId}`);
|
|
540
|
+
node.name = `${prefix}${stripPrefix(name)}`;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function createModuleWrapper(slide, moduleGuid) {
|
|
545
|
+
return {
|
|
546
|
+
guid: deepClone(moduleGuid),
|
|
547
|
+
phase: 'CREATED',
|
|
548
|
+
type: 'MODULE',
|
|
549
|
+
name: slide.name ?? 'layout',
|
|
550
|
+
isPublishable: true,
|
|
551
|
+
version: MODULE_VERSION,
|
|
552
|
+
userFacingVersion: MODULE_VERSION,
|
|
553
|
+
visible: true,
|
|
554
|
+
opacity: 1,
|
|
555
|
+
size: deepClone(slide.size ?? { x: 1920, y: 1080 }),
|
|
556
|
+
transform: { m00: 1, m01: 0, m02: slide.transform?.m02 ?? 0, m10: 0, m11: 1, m12: slide.transform?.m12 ?? 0 },
|
|
557
|
+
strokeWeight: 1,
|
|
558
|
+
strokeAlign: 'INSIDE',
|
|
559
|
+
strokeJoin: 'MITER',
|
|
560
|
+
fillPaints: deepClone(slide.fillPaints ?? [{
|
|
561
|
+
type: 'SOLID',
|
|
562
|
+
color: { r: 1, g: 1, b: 1, a: 1 },
|
|
563
|
+
opacity: 1,
|
|
564
|
+
visible: true,
|
|
565
|
+
blendMode: 'NORMAL',
|
|
566
|
+
}]),
|
|
567
|
+
fillGeometry: [{
|
|
568
|
+
windingRule: 'NONZERO',
|
|
569
|
+
commandsBlob: 13,
|
|
570
|
+
styleID: 0,
|
|
571
|
+
}],
|
|
572
|
+
frameMaskDisabled: false,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function getParentModule(deck, slide) {
|
|
577
|
+
if (!slide?.parentIndex?.guid) return null;
|
|
578
|
+
const parent = deck.getNode(guidId(slide.parentIndex.guid));
|
|
579
|
+
return parent?.type === 'MODULE' ? parent : null;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function getAncestorCanvas(deck, node) {
|
|
583
|
+
let current = node;
|
|
584
|
+
while (current?.parentIndex?.guid) {
|
|
585
|
+
const parent = deck.getNode(guidId(current.parentIndex.guid));
|
|
586
|
+
if (!parent) return null;
|
|
587
|
+
if (parent.type === 'CANVAS') return parent;
|
|
588
|
+
current = parent;
|
|
589
|
+
}
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function isInternalCanvas(canvas) {
|
|
594
|
+
return canvas?.name === INTERNAL_CANVAS_NAME;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function parsePrefixedName(value, prefix) {
|
|
598
|
+
if (!value || !value.startsWith(prefix)) return null;
|
|
599
|
+
return stripPrefix(value.slice(prefix.length));
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function normalizeLayoutName(value) {
|
|
603
|
+
const stripped = stripLayoutPrefix(value || 'layout');
|
|
604
|
+
return `${LAYOUT_PREFIX}${stripped}`;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function stripLayoutPrefix(value) {
|
|
608
|
+
return parsePrefixedName(value, LAYOUT_PREFIX) ?? stripPrefix(value);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function stripPrefix(value) {
|
|
612
|
+
return String(value ?? '').trim().replace(/^(layout:|slot:text:|slot:image:|fixed:image:)/, '');
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function firstShapeText(node) {
|
|
616
|
+
const text = node.nodeGenerationData?.overrides?.find(override => override.textData?.characters)?.textData?.characters ?? '';
|
|
617
|
+
return text.trim().slice(0, 80);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function existingImageScaleMode(node) {
|
|
621
|
+
return node.fillPaints?.find(fill => fill.type === 'IMAGE')?.imageScaleMode;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function dedupe(values) {
|
|
625
|
+
return [...new Set(values.filter(Boolean))];
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function guidId(guid) {
|
|
629
|
+
return guid ? `${guid.sessionID}:${guid.localID}` : null;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function sha1Hex(buf) {
|
|
633
|
+
return createHash('sha1').update(buf).digest('hex');
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function copyToImagesDir(deck, hash, srcPath) {
|
|
637
|
+
if (!deck.imagesDir) {
|
|
638
|
+
deck.imagesDir = `/tmp/figmatk_images_${Date.now()}`;
|
|
639
|
+
mkdirSync(deck.imagesDir, { recursive: true });
|
|
640
|
+
}
|
|
641
|
+
const dest = join(deck.imagesDir, hash);
|
|
642
|
+
if (!existsSync(dest)) copyFileSync(srcPath, dest);
|
|
643
|
+
}
|
package/mcp-server.mjs
CHANGED
|
@@ -7,8 +7,15 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { FigDeck } from './lib/fig-deck.mjs';
|
|
9
9
|
import { Deck } from './lib/api.mjs';
|
|
10
|
-
import {
|
|
11
|
-
|
|
10
|
+
import {
|
|
11
|
+
annotateTemplateLayout,
|
|
12
|
+
createDraftTemplate,
|
|
13
|
+
createFromTemplate,
|
|
14
|
+
listTemplateLayouts,
|
|
15
|
+
publishTemplateDraft,
|
|
16
|
+
} from './lib/template-deck.mjs';
|
|
17
|
+
import { nid, ov, removeNode } from './lib/node-helpers.mjs';
|
|
18
|
+
import { imageOv, hashToHex } from './lib/image-helpers.mjs';
|
|
12
19
|
import { deepClone } from './lib/deep-clone.mjs';
|
|
13
20
|
|
|
14
21
|
const server = new McpServer({
|
|
@@ -45,7 +52,7 @@ server.tool(
|
|
|
45
52
|
// ── list-text ───────────────────────────────────────────────────────────
|
|
46
53
|
server.tool(
|
|
47
54
|
'figmatk_list_text',
|
|
48
|
-
'List
|
|
55
|
+
'List visible text and image content per slide in a .deck file, including direct slide nodes and instance overrides.',
|
|
49
56
|
{ path: z.string().describe('Path to .deck or .fig file') },
|
|
50
57
|
async ({ path }) => {
|
|
51
58
|
const deck = await FigDeck.fromDeckFile(path);
|
|
@@ -55,18 +62,41 @@ server.tool(
|
|
|
55
62
|
if (slide.phase === 'REMOVED') continue;
|
|
56
63
|
const id = nid(slide);
|
|
57
64
|
lines.push(`\n── Slide ${id} "${slide.name || ''}" ──`);
|
|
65
|
+
|
|
66
|
+
const directLines = [];
|
|
67
|
+
deck.walkTree(id, (node, depth) => {
|
|
68
|
+
if (depth === 0 || node.phase === 'REMOVED') return;
|
|
69
|
+
if (node.type === 'TEXT' && node.textData?.characters) {
|
|
70
|
+
directLines.push(` [text-node] ${nid(node)} "${node.name || ''}": ${node.textData.characters.substring(0, 120)}`);
|
|
71
|
+
}
|
|
72
|
+
if (node.type === 'SHAPE_WITH_TEXT' && node.nodeGenerationData?.overrides) {
|
|
73
|
+
for (const override of node.nodeGenerationData.overrides) {
|
|
74
|
+
if (override.textData?.characters) {
|
|
75
|
+
directLines.push(` [shape-text] ${nid(node)} "${node.name || ''}": ${override.textData.characters.substring(0, 120)}`);
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const imageFill = node.fillPaints?.find(p => p.type === 'IMAGE' && p.image?.hash);
|
|
81
|
+
if (imageFill) {
|
|
82
|
+
directLines.push(` [image-node] ${nid(node)} "${node.name || ''}": ${hashToHex(imageFill.image.hash)}`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
lines.push(...directLines);
|
|
87
|
+
|
|
58
88
|
const inst = deck.getSlideInstance(id);
|
|
59
89
|
if (!inst?.symbolData?.symbolOverrides) continue;
|
|
60
90
|
for (const ov of inst.symbolData.symbolOverrides) {
|
|
61
91
|
const key = ov.guidPath?.guids?.[0];
|
|
62
92
|
const keyStr = key ? `${key.sessionID}:${key.localID}` : '?';
|
|
63
93
|
if (ov.textData?.characters) {
|
|
64
|
-
lines.push(` [text] ${keyStr}: ${ov.textData.characters.substring(0, 120)}`);
|
|
94
|
+
lines.push(` [text-override] ${keyStr}: ${ov.textData.characters.substring(0, 120)}`);
|
|
65
95
|
}
|
|
66
96
|
if (ov.fillPaints?.length) {
|
|
67
97
|
for (const p of ov.fillPaints) {
|
|
68
98
|
if (p.image?.hash) {
|
|
69
|
-
lines.push(` [image] ${keyStr}: ${hashToHex(p.image.hash)}`);
|
|
99
|
+
lines.push(` [image-override] ${keyStr}: ${hashToHex(p.image.hash)}`);
|
|
70
100
|
}
|
|
71
101
|
}
|
|
72
102
|
}
|
|
@@ -92,13 +122,13 @@ server.tool(
|
|
|
92
122
|
function walkChildren(nodeId, depth) {
|
|
93
123
|
const node = deck.getNode(nodeId);
|
|
94
124
|
if (!node || node.phase === 'REMOVED') return;
|
|
95
|
-
const
|
|
125
|
+
const key = node.overrideKey ? `${node.overrideKey.sessionID}:${node.overrideKey.localID}` : null;
|
|
96
126
|
const type = node.type || '?';
|
|
97
127
|
const name = node.name || '';
|
|
98
|
-
if (type === 'TEXT' ||
|
|
99
|
-
lines.push(` ${' '.repeat(depth)}${type} ${
|
|
128
|
+
if (key && (type === 'TEXT' || node.fillPaints?.some(p => p.type === 'IMAGE'))) {
|
|
129
|
+
lines.push(` ${' '.repeat(depth)}${type} ${key} "${name}"`);
|
|
100
130
|
}
|
|
101
|
-
const kids = deck.childrenMap.get(
|
|
131
|
+
const kids = deck.childrenMap.get(nid(node)) || [];
|
|
102
132
|
for (const kid of kids) walkChildren(nid(kid), depth + 1);
|
|
103
133
|
}
|
|
104
134
|
for (const child of children) walkChildren(nid(child), 0);
|
|
@@ -126,7 +156,18 @@ server.tool(
|
|
|
126
156
|
|
|
127
157
|
for (const [key, text] of Object.entries(overrides)) {
|
|
128
158
|
const [s, l] = key.split(':').map(Number);
|
|
129
|
-
|
|
159
|
+
const nextOverride = ov({ sessionID: s, localID: l }, text);
|
|
160
|
+
const existingIdx = inst.symbolData.symbolOverrides.findIndex(entry =>
|
|
161
|
+
entry.guidPath?.guids?.length >= 1 &&
|
|
162
|
+
entry.guidPath.guids[0].sessionID === s &&
|
|
163
|
+
entry.guidPath.guids[0].localID === l &&
|
|
164
|
+
entry.textData
|
|
165
|
+
);
|
|
166
|
+
if (existingIdx >= 0) {
|
|
167
|
+
inst.symbolData.symbolOverrides.splice(existingIdx, 1, nextOverride);
|
|
168
|
+
} else {
|
|
169
|
+
inst.symbolData.symbolOverrides.push(nextOverride);
|
|
170
|
+
}
|
|
130
171
|
}
|
|
131
172
|
|
|
132
173
|
const bytes = await deck.saveDeck(output);
|
|
@@ -157,9 +198,18 @@ server.tool(
|
|
|
157
198
|
if (!inst.symbolData.symbolOverrides) inst.symbolData.symbolOverrides = [];
|
|
158
199
|
|
|
159
200
|
const [s, l] = targetKey.split(':').map(Number);
|
|
160
|
-
|
|
161
|
-
|
|
201
|
+
const nextOverride = imageOv({ sessionID: s, localID: l }, imageHash, thumbHash, width, height);
|
|
202
|
+
const existingIdx = inst.symbolData.symbolOverrides.findIndex(entry =>
|
|
203
|
+
entry.guidPath?.guids?.length >= 1 &&
|
|
204
|
+
entry.guidPath.guids[0].sessionID === s &&
|
|
205
|
+
entry.guidPath.guids[0].localID === l &&
|
|
206
|
+
entry.fillPaints
|
|
162
207
|
);
|
|
208
|
+
if (existingIdx >= 0) {
|
|
209
|
+
inst.symbolData.symbolOverrides.splice(existingIdx, 1, nextOverride);
|
|
210
|
+
} else {
|
|
211
|
+
inst.symbolData.symbolOverrides.push(nextOverride);
|
|
212
|
+
}
|
|
163
213
|
|
|
164
214
|
const opts = imagesDir ? { imagesDir } : {};
|
|
165
215
|
const bytes = await deck.saveDeck(output, opts);
|
|
@@ -336,6 +386,95 @@ server.tool(
|
|
|
336
386
|
}
|
|
337
387
|
);
|
|
338
388
|
|
|
389
|
+
// ── figmatk_list_template_layouts ────────────────────────────────────────
|
|
390
|
+
server.tool(
|
|
391
|
+
'figmatk_create_template_draft',
|
|
392
|
+
'Create a new draft template deck. Draft templates are normal slide decks; later annotate slots and publish-wrap them into module-backed layouts.',
|
|
393
|
+
{
|
|
394
|
+
output: z.string().describe('Output path for the draft template .deck file'),
|
|
395
|
+
title: z.string().describe('Template deck title'),
|
|
396
|
+
layouts: z.array(z.string()).optional().describe('Optional ordered list of layout names to create, e.g. ["cover", "agenda", "section"]'),
|
|
397
|
+
},
|
|
398
|
+
async ({ output, title, layouts }) => {
|
|
399
|
+
const bytes = await createDraftTemplate(output, { title, layouts });
|
|
400
|
+
return { content: [{ type: 'text', text: `Created draft template ${output} (${bytes} bytes). Use figmatk_annotate_template_layout to mark layout and slot names.` }] };
|
|
401
|
+
}
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
server.tool(
|
|
405
|
+
'figmatk_annotate_template_layout',
|
|
406
|
+
'Add explicit layout and slot metadata to a draft or published template. Use figmatk_inspect or figmatk_list_template_layouts first to get slide and node IDs.',
|
|
407
|
+
{
|
|
408
|
+
path: z.string().describe('Path to the source .deck file'),
|
|
409
|
+
output: z.string().describe('Output path for the updated .deck file'),
|
|
410
|
+
slideId: z.string().describe('Slide node ID to annotate'),
|
|
411
|
+
layoutName: z.string().optional().describe('Logical layout name without the layout: prefix, e.g. "cover"'),
|
|
412
|
+
textSlots: z.record(z.string()).optional().describe('Map of nodeId -> text slot name, e.g. {"1:120": "title"}'),
|
|
413
|
+
imageSlots: z.record(z.string()).optional().describe('Map of nodeId -> image slot name, e.g. {"1:144": "hero_image"}'),
|
|
414
|
+
fixedImages: z.record(z.string()).optional().describe('Map of nodeId -> fixed image label for decorative/sample content'),
|
|
415
|
+
},
|
|
416
|
+
async ({ path, output, slideId, layoutName, textSlots, imageSlots, fixedImages }) => {
|
|
417
|
+
const bytes = await annotateTemplateLayout(path, output, { slideId, layoutName, textSlots, imageSlots, fixedImages });
|
|
418
|
+
return { content: [{ type: 'text', text: `Annotated slide ${slideId}. Saved ${output} (${bytes} bytes).` }] };
|
|
419
|
+
}
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
server.tool(
|
|
423
|
+
'figmatk_publish_template_draft',
|
|
424
|
+
'Wrap draft template slides in publish-like MODULE nodes while preserving the slide subtree and internal canvas assets.',
|
|
425
|
+
{
|
|
426
|
+
path: z.string().describe('Path to the draft template .deck file'),
|
|
427
|
+
output: z.string().describe('Output path for the wrapped .deck file'),
|
|
428
|
+
slideIds: z.array(z.string()).optional().describe('Optional list of draft slide IDs to wrap. Defaults to every draft layout on the main canvas.'),
|
|
429
|
+
},
|
|
430
|
+
async ({ path, output, slideIds }) => {
|
|
431
|
+
const bytes = await publishTemplateDraft(path, output, { slideIds });
|
|
432
|
+
return { content: [{ type: 'text', text: `Publish-wrapped draft template to ${output} (${bytes} bytes).` }] };
|
|
433
|
+
}
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
server.tool(
|
|
437
|
+
'figmatk_list_template_layouts',
|
|
438
|
+
'Inspect a template or draft template .deck file and return available layouts with explicit text/image slot metadata. Call this before figmatk_create_from_template or figmatk_annotate_template_layout.',
|
|
439
|
+
{
|
|
440
|
+
template: z.string().describe('Path to the .deck template file'),
|
|
441
|
+
},
|
|
442
|
+
async ({ template }) => {
|
|
443
|
+
const layouts = await listTemplateLayouts(template);
|
|
444
|
+
const lines = layouts.map(l => {
|
|
445
|
+
const textSlots = l.textFields.map(f => ` - ${f.name} (${f.nodeId}, ${f.source}): "${f.preview}"`).join('\n');
|
|
446
|
+
const imageSlots = l.imagePlaceholders.map(f => ` - ${f.name} (${f.nodeId}, ${f.source}, ${f.width}x${f.height})${f.hasCurrentImage ? ' [image]' : ''}`).join('\n');
|
|
447
|
+
return [
|
|
448
|
+
`Layout "${l.name}" [${l.slideId}]`,
|
|
449
|
+
` state: ${l.state}${l.moduleId ? `, module ${l.moduleId}` : ''}, row ${l.rowId}`,
|
|
450
|
+
` explicit slots: ${l.hasExplicitSlotMetadata ? 'yes' : 'no'}`,
|
|
451
|
+
textSlots ? ` text slots:\n${textSlots}` : ' text slots: (none)',
|
|
452
|
+
imageSlots ? ` image slots:\n${imageSlots}` : ' image slots: (none)',
|
|
453
|
+
].join('\n');
|
|
454
|
+
});
|
|
455
|
+
return { content: [{ type: 'text', text: lines.join('\n\n') }] };
|
|
456
|
+
}
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
// ── figmatk_create_from_template ─────────────────────────────────────────
|
|
460
|
+
server.tool(
|
|
461
|
+
'figmatk_create_from_template',
|
|
462
|
+
'Create a new Figma Slides deck by cherry-picking layouts from a draft, published, or publish-like template .deck file and populating explicit text/image slots. Preserves colors, fonts, internal assets, and special nodes.',
|
|
463
|
+
{
|
|
464
|
+
template: z.string().describe('Path to the source .deck template file'),
|
|
465
|
+
output: z.string().describe('Output path for the new .deck file (use /tmp/)'),
|
|
466
|
+
slides: z.array(z.object({
|
|
467
|
+
slideId: z.string().describe('Slide ID from figmatk_list_template_layouts (e.g. "1:74")'),
|
|
468
|
+
text: z.record(z.string()).optional().describe('Map of text slot/name/nodeId -> value (e.g. { "title": "My Company" })'),
|
|
469
|
+
images: z.record(z.string()).optional().describe('Map of image slot/name/nodeId -> absolute image path (e.g. { "hero_image": "/tmp/photo.jpg" })'),
|
|
470
|
+
})).describe('Ordered list of slides to include, each referencing a template layout'),
|
|
471
|
+
},
|
|
472
|
+
async ({ template, output, slides }) => {
|
|
473
|
+
const bytes = await createFromTemplate(template, output, slides);
|
|
474
|
+
return { content: [{ type: 'text', text: `Created ${output} — ${slides.length} slides (${bytes} bytes). Open in Figma Desktop.` }] };
|
|
475
|
+
}
|
|
476
|
+
);
|
|
477
|
+
|
|
339
478
|
// ── Start server ────────────────────────────────────────────────────────
|
|
340
479
|
const transport = new StdioServerTransport();
|
|
341
480
|
await server.connect(transport);
|
package/package.json
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: figma-slides-creator
|
|
3
3
|
description: >
|
|
4
|
-
Create, edit, and inspect Figma Slides .deck files. Use when the
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
Create, populate, edit, and inspect Figma Slides .deck files. Use when the
|
|
5
|
+
user wants a finished presentation deck, wants to fill an existing template
|
|
6
|
+
with content, or wants to edit a non-template deck's text, images, or slide
|
|
7
|
+
order. Do not use this skill to author reusable templates themselves.
|
|
7
8
|
Powered by FigmaTK under the hood.
|
|
8
9
|
metadata:
|
|
9
|
-
version: "0.
|
|
10
|
+
version: "0.3.1"
|
|
10
11
|
---
|
|
11
12
|
|
|
12
13
|
# Figma Slides Creator
|
|
13
14
|
|
|
15
|
+
Use this skill for the default workflow: take an existing template and build a new presentation from it. For authoring reusable templates themselves, use `skills/figma-template-builder/SKILL.md`.
|
|
16
|
+
|
|
17
|
+
## Skill Boundary
|
|
18
|
+
|
|
19
|
+
Use this skill when the outcome is a finished deck for immediate use.
|
|
20
|
+
|
|
21
|
+
Switch to `skills/figma-template-builder/SKILL.md` when the user wants to:
|
|
22
|
+
|
|
23
|
+
- build a reusable template
|
|
24
|
+
- define layouts or placeholders
|
|
25
|
+
- rename slots for future sessions
|
|
26
|
+
- derive a new template system from references or examples
|
|
27
|
+
|
|
14
28
|
## ⚠️ Never open .deck files directly
|
|
15
29
|
|
|
16
30
|
`.deck` files are binary ZIP archives. **Never open, read, or display a `.deck` file** — it will show garbage bytes in the panel. To inspect or modify a `.deck` file, always use the CLI commands or Node.js API shown below.
|
|
@@ -23,7 +37,9 @@ To let the user view the result: tell them to **open the file in Figma Desktop**
|
|
|
23
37
|
|
|
24
38
|
| Task | Approach |
|
|
25
39
|
|------|----------|
|
|
26
|
-
| Create
|
|
40
|
+
| Create from scratch | **Path A** — `figmatk_create_deck` MCP tool |
|
|
41
|
+
| Create from a `.deck` template | **Path B** — `figmatk_list_template_layouts` + `figmatk_create_from_template` |
|
|
42
|
+
| Author a reusable template | Use `skills/figma-template-builder/SKILL.md` |
|
|
27
43
|
| Edit text or images in an existing deck | `figmatk_update_text`, `figmatk_insert_image` |
|
|
28
44
|
| Clone, remove, or restructure slides | `figmatk_clone_slide`, `figmatk_remove_slide` |
|
|
29
45
|
| Inspect structure or read content | `figmatk_inspect`, `figmatk_list_text` |
|
|
@@ -34,9 +50,58 @@ To let the user view the result: tell them to **open the file in Figma Desktop**
|
|
|
34
50
|
|
|
35
51
|
**All files go in `/tmp/`** — scripts, output decks, images, everything. Never write to the Desktop, Documents, Downloads, or any user directory. Never create intermediate notes or reference markdown files. Just build and save the deck.
|
|
36
52
|
|
|
53
|
+
## Default Workflow
|
|
54
|
+
|
|
55
|
+
1. Inspect the template or deck.
|
|
56
|
+
2. Pick the minimum set of layouts or edits needed.
|
|
57
|
+
3. Populate text slots first, then image slots.
|
|
58
|
+
4. Save to a new `/tmp/` output path.
|
|
59
|
+
5. Sanity-check the result with `figmatk_list_text` or by opening it in Figma Desktop.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Path B — Create from a Template (preferred when user provides a .deck file)
|
|
64
|
+
|
|
65
|
+
Use this path when the user provides a `.deck` template file. The output deck inherits all fonts, colors, spacing, and visual design from the template verbatim.
|
|
66
|
+
|
|
67
|
+
### Step 1 — Inspect the template
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
figmatk_list_template_layouts("/path/to/template.deck")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Returns a catalog of all available slide layouts. Each entry includes:
|
|
74
|
+
- `slideId` — the ID to reference this layout
|
|
75
|
+
- Layout state — `draft` or `published`
|
|
76
|
+
- Text slots — explicit `slot:text:*` fields when present, otherwise fallback text candidates
|
|
77
|
+
- Image slots — explicit `slot:image:*` fields when present, otherwise fallback image candidates
|
|
78
|
+
- Node IDs — usable for direct targeting when the template has not been fully annotated yet
|
|
79
|
+
|
|
80
|
+
**Read the catalog carefully before picking layouts:**
|
|
81
|
+
- Prefer layouts with explicit slot metadata when available
|
|
82
|
+
- Match each slide's purpose to your content; the existing copy is often the best hint
|
|
83
|
+
- Use slot names first, then node IDs, then raw node names when populating content
|
|
84
|
+
- If a layout exposes no explicit image slots, treat heuristic image candidates as weaker signals and avoid overwriting decorative sample imagery unless the user clearly wants that
|
|
85
|
+
|
|
86
|
+
### Step 2 — Create the deck
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
figmatk_create_from_template({
|
|
90
|
+
template: "/path/to/template.deck",
|
|
91
|
+
output: "/tmp/my-deck.deck",
|
|
92
|
+
slides: [
|
|
93
|
+
{ slideId: "1:74", text: { "title": "My Company" } },
|
|
94
|
+
{ slideId: "1:112", text: { "header": "The problem.", "body": "Description here." }, images: { "hero_image": "/tmp/problem-photo.jpg" } },
|
|
95
|
+
{ slideId: "1:643", text: { "title": "Thank you!" } }
|
|
96
|
+
]
|
|
97
|
+
})
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Only pass slots or node IDs that exist in the layout's catalog. Extra keys are silently ignored.
|
|
101
|
+
|
|
37
102
|
---
|
|
38
103
|
|
|
39
|
-
## Path A — Create from Scratch (MCP tool —
|
|
104
|
+
## Path A — Create from Scratch (MCP tool — no template)
|
|
40
105
|
|
|
41
106
|
**Always use this path.** No npm install, no scripts, no workspace setup.
|
|
42
107
|
|
|
@@ -211,6 +276,14 @@ Use this when the user provides a `.deck` file to modify.
|
|
|
211
276
|
| `figmatk_remove_slide` | Mark slides as REMOVED (never deleted) |
|
|
212
277
|
| `figmatk_roundtrip` | Decode + re-encode for pipeline validation |
|
|
213
278
|
|
|
279
|
+
## Final Checks
|
|
280
|
+
|
|
281
|
+
Before finishing, prefer at least one of these:
|
|
282
|
+
|
|
283
|
+
- `figmatk_list_text` on the output deck
|
|
284
|
+
- `figmatk_roundtrip` if the deck went through multiple edits
|
|
285
|
+
- a manual open check in Figma Desktop when the user is validating upload/render behavior
|
|
286
|
+
|
|
214
287
|
---
|
|
215
288
|
|
|
216
289
|
## Design Philosophy
|
|
@@ -289,7 +362,7 @@ Every slide needs at least **one visual element** — shape, image, SVG, or tabl
|
|
|
289
362
|
- Centre body text
|
|
290
363
|
- Use accent lines under slide titles (hallmark of AI-generated slides)
|
|
291
364
|
- Text-only slides
|
|
292
|
-
- Low-contrast text against background
|
|
365
|
+
- Low-contrast text against background — **match image tone to slide palette**: dark/moody images on light-background slides make text unreadable; pick a bright image or switch to a dark-background layout
|
|
293
366
|
- Skip the closing slide — it makes the deck feel unfinished
|
|
294
367
|
- Put long paragraphs in body/caption fields — text overflows the container
|
|
295
368
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: figma-template-builder
|
|
3
|
+
description: >
|
|
4
|
+
Author reusable Figma Slides templates as .deck files. Use when the user
|
|
5
|
+
wants to build a template from reference images or examples, derive a new
|
|
6
|
+
template from an existing deck, define reusable layouts, mark editable
|
|
7
|
+
text/image slots, or prepare a draft template for later instantiation.
|
|
8
|
+
metadata:
|
|
9
|
+
version: "0.1.0"
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Figma Template Builder
|
|
13
|
+
|
|
14
|
+
Use this skill when the goal is to build or refine the template itself. For the common workflow of taking an existing template and producing a new presentation, use `skills/figma-slides-creator/SKILL.md`.
|
|
15
|
+
|
|
16
|
+
## Skill Boundary
|
|
17
|
+
|
|
18
|
+
Use this skill when the deliverable is a reusable template, not a one-off deck.
|
|
19
|
+
|
|
20
|
+
Stay here when the user wants to:
|
|
21
|
+
|
|
22
|
+
- translate reference images or screenshots into template layouts
|
|
23
|
+
- create a new layout family
|
|
24
|
+
- define placeholder semantics for later sessions
|
|
25
|
+
- turn an ordinary deck into a reusable template
|
|
26
|
+
|
|
27
|
+
Hand off to `skills/figma-slides-creator/SKILL.md` once the template exists and the task becomes simple content population.
|
|
28
|
+
|
|
29
|
+
## Core Model
|
|
30
|
+
|
|
31
|
+
Template work happens in two states:
|
|
32
|
+
|
|
33
|
+
- Draft template: `SLIDE_ROW -> SLIDE -> ...`
|
|
34
|
+
- Published or publish-like template: `SLIDE_ROW -> MODULE -> SLIDE -> ...`
|
|
35
|
+
|
|
36
|
+
Draft templates are easier to author. Publish-like wrapping is the final step before later instantiation.
|
|
37
|
+
|
|
38
|
+
## Design-First Workflow
|
|
39
|
+
|
|
40
|
+
When the user provides reference images, screenshots, or example decks:
|
|
41
|
+
|
|
42
|
+
1. Read the references first and infer the layout family before touching the `.deck`.
|
|
43
|
+
2. Decide which parts are reusable structure versus one-off sample content.
|
|
44
|
+
3. Create only the smallest set of layouts that captures the system.
|
|
45
|
+
4. Use semantic slot names so later sessions can populate them without re-reading the design intent.
|
|
46
|
+
|
|
47
|
+
Do not mirror every visual variation as its own layout unless the content structure changes materially.
|
|
48
|
+
|
|
49
|
+
## Naming Conventions
|
|
50
|
+
|
|
51
|
+
Always use explicit metadata when authoring reusable layouts:
|
|
52
|
+
|
|
53
|
+
- Layouts: `layout:<name>`
|
|
54
|
+
- Text slots: `slot:text:<name>`
|
|
55
|
+
- Image slots: `slot:image:<name>`
|
|
56
|
+
- Decorative fixed imagery: `fixed:image:<name>`
|
|
57
|
+
|
|
58
|
+
These conventions are how later sessions discover what is editable.
|
|
59
|
+
|
|
60
|
+
Prefer semantic names over visual or auto-generated names.
|
|
61
|
+
|
|
62
|
+
Good:
|
|
63
|
+
|
|
64
|
+
- `title`
|
|
65
|
+
- `subtitle`
|
|
66
|
+
- `hero_image`
|
|
67
|
+
- `device_screen_primary`
|
|
68
|
+
- `quote_author`
|
|
69
|
+
|
|
70
|
+
Bad:
|
|
71
|
+
|
|
72
|
+
- `text1`
|
|
73
|
+
- `frame183`
|
|
74
|
+
- `image-left`
|
|
75
|
+
- `rectangle2`
|
|
76
|
+
|
|
77
|
+
## Recommended Flow
|
|
78
|
+
|
|
79
|
+
### 1. Create or inspect a draft template
|
|
80
|
+
|
|
81
|
+
- New draft from scratch: `figmatk_create_template_draft`
|
|
82
|
+
- Existing deck/template: `figmatk_list_template_layouts`
|
|
83
|
+
- Structural inspection: `figmatk_inspect`
|
|
84
|
+
|
|
85
|
+
### 2. Annotate reusable layouts
|
|
86
|
+
|
|
87
|
+
Use `figmatk_annotate_template_layout` to:
|
|
88
|
+
|
|
89
|
+
- rename a slide as a layout
|
|
90
|
+
- mark text nodes as editable text slots
|
|
91
|
+
- mark image-bearing nodes as editable image slots
|
|
92
|
+
- mark decorative imagery as fixed
|
|
93
|
+
|
|
94
|
+
The tool accepts node ID maps, so inspect first if you need the raw node IDs.
|
|
95
|
+
|
|
96
|
+
While annotating:
|
|
97
|
+
|
|
98
|
+
- rename the slide itself to the stable layout name
|
|
99
|
+
- mark only true placeholders as `slot:*`
|
|
100
|
+
- mark decorative or sample imagery as `fixed:image:*`
|
|
101
|
+
- prefer stable semantic names over spatial names like `left_box`
|
|
102
|
+
|
|
103
|
+
### 3. Publish-wrap when the template is ready
|
|
104
|
+
|
|
105
|
+
Use `figmatk_publish_template_draft` to add publish-like `MODULE` wrappers while preserving the slide subtree and internal assets.
|
|
106
|
+
|
|
107
|
+
### 4. Verify the result
|
|
108
|
+
|
|
109
|
+
- `figmatk_list_template_layouts`
|
|
110
|
+
- `figmatk_list_text`
|
|
111
|
+
- `figmatk_roundtrip` if you want a conservative encode/decode check
|
|
112
|
+
- open the wrapped template in Figma Desktop when validating real upload behavior
|
|
113
|
+
|
|
114
|
+
## Practical Rules
|
|
115
|
+
|
|
116
|
+
- Prefer explicit slot names over heuristic placeholders.
|
|
117
|
+
- Do not assume every image fill is editable content.
|
|
118
|
+
- Preserve `Internal Only Canvas` assets.
|
|
119
|
+
- Preserve special nodes such as device mockups and interactive slide elements; do not try to recreate them from scratch unless necessary.
|
|
120
|
+
|
|
121
|
+
## Template Authoring Heuristics
|
|
122
|
+
|
|
123
|
+
- Start with 4-8 reusable layouts, not an exhaustive library.
|
|
124
|
+
- Reuse one layout when only copy length changes; create a new layout when hierarchy or media structure changes.
|
|
125
|
+
- Separate content slots from chrome. For device mockups, the screen is usually the slot and the hardware frame is usually fixed.
|
|
126
|
+
- If a layout has explicit slot metadata, do not rely on heuristic image placeholders for that layout.
|
|
127
|
+
- After publish-wrapping, re-run `figmatk_list_template_layouts` and confirm the layout names and slot names survived unchanged.
|
|
128
|
+
|
|
129
|
+
## Example
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"path": "/tmp/draft-template.deck",
|
|
134
|
+
"output": "/tmp/draft-template-annotated.deck",
|
|
135
|
+
"slideId": "1:42",
|
|
136
|
+
"layoutName": "cover",
|
|
137
|
+
"textSlots": {
|
|
138
|
+
"1:120": "title",
|
|
139
|
+
"1:121": "subtitle"
|
|
140
|
+
},
|
|
141
|
+
"imageSlots": {
|
|
142
|
+
"1:144": "hero_image"
|
|
143
|
+
},
|
|
144
|
+
"fixedImages": {
|
|
145
|
+
"1:199": "background_texture"
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|