figmatk 0.2.6 → 0.3.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.
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* template-deck — Clone slides from a Figma template and populate with content.
|
|
3
|
+
*
|
|
4
|
+
* Template decks use MODULE > SLIDE > (TEXT, FRAME, ...) structure.
|
|
5
|
+
* Unlike generated decks, text is set directly on child nodes — not via symbolOverrides.
|
|
6
|
+
*/
|
|
7
|
+
import { FigDeck } from './fig-deck.mjs';
|
|
8
|
+
import { nid, removeNode } from './node-helpers.mjs';
|
|
9
|
+
import { deepClone } from './deep-clone.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Inspect a template deck and return available layout slots.
|
|
13
|
+
* Returns an array of { slideId, name, textFields: [{ nodeId, name }] }
|
|
14
|
+
*/
|
|
15
|
+
export async function listTemplateLayouts(templatePath) {
|
|
16
|
+
const deck = await FigDeck.fromDeckFile(templatePath);
|
|
17
|
+
const layouts = [];
|
|
18
|
+
|
|
19
|
+
for (const slide of deck.getActiveSlides()) {
|
|
20
|
+
const id = nid(slide);
|
|
21
|
+
const textFields = [];
|
|
22
|
+
const imagePlaceholders = [];
|
|
23
|
+
|
|
24
|
+
deck.walkTree(id, (node) => {
|
|
25
|
+
if (node.type === 'TEXT' && node.name && node.textData?.characters) {
|
|
26
|
+
textFields.push({ nodeId: nid(node), name: node.name, preview: node.textData.characters.slice(0, 80) });
|
|
27
|
+
}
|
|
28
|
+
// SHAPE_WITH_TEXT: text lives in nodeGenerationData.overrides[n].textData
|
|
29
|
+
if (node.type === 'SHAPE_WITH_TEXT' && node.nodeGenerationData?.overrides) {
|
|
30
|
+
for (const ov of node.nodeGenerationData.overrides) {
|
|
31
|
+
if (ov.textData?.characters) {
|
|
32
|
+
const preview = ov.textData.characters.trim();
|
|
33
|
+
// Use #nodeId as key so createFromTemplate can target this specific field
|
|
34
|
+
textFields.push({ nodeId: nid(node), name: `#${nid(node)}`, preview });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Detect image placeholder areas: frames/shapes with IMAGE fill, or large empty frames
|
|
39
|
+
const hasImageFill = node.fillPaints?.some(f => f.type === 'IMAGE');
|
|
40
|
+
const isLargeFrame = (node.type === 'FRAME' || node.type === 'ROUNDED_RECTANGLE')
|
|
41
|
+
&& node.size?.x > 100 && node.size?.y > 100
|
|
42
|
+
&& deck.getChildren(nid(node)).filter(c => c.type !== 'FRAME').length === 0;
|
|
43
|
+
if (hasImageFill || isLargeFrame) {
|
|
44
|
+
imagePlaceholders.push({
|
|
45
|
+
nodeId: nid(node),
|
|
46
|
+
type: node.type,
|
|
47
|
+
width: Math.round(node.size?.x ?? 0),
|
|
48
|
+
height: Math.round(node.size?.y ?? 0),
|
|
49
|
+
hasCurrentImage: hasImageFill,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
layouts.push({ slideId: id, name: slide.name, textFields, imagePlaceholders });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return layouts;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Create a new deck from a template by cherry-picking and populating slides.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} templatePath - Path to source .deck file
|
|
64
|
+
* @param {string} outputPath - Path to write output .deck file
|
|
65
|
+
* @param {Array} slideDefs - [{ slideId: '1:74', text: { 'Title': 'Hello', 'Body 1': '...' } }]
|
|
66
|
+
* @returns {number} bytes written
|
|
67
|
+
*/
|
|
68
|
+
export async function createFromTemplate(templatePath, outputPath, slideDefs) {
|
|
69
|
+
const deck = await FigDeck.fromDeckFile(templatePath);
|
|
70
|
+
|
|
71
|
+
// Record original MODULE/SLIDE IDs to remove after cloning
|
|
72
|
+
const originalSlideIds = deck.getActiveSlides().map(s => nid(s));
|
|
73
|
+
|
|
74
|
+
// Find SLIDE_ROW id (parent of MODULEs)
|
|
75
|
+
const slideRowNode = deck.message.nodeChanges.find(n => n.type === 'SLIDE_ROW');
|
|
76
|
+
if (!slideRowNode) throw new Error('No SLIDE_ROW found in template');
|
|
77
|
+
const slideRowId = nid(slideRowNode);
|
|
78
|
+
|
|
79
|
+
let nextId = deck.maxLocalID() + 1;
|
|
80
|
+
const SESSION = 200; // fresh session ID for cloned nodes
|
|
81
|
+
|
|
82
|
+
for (let defIdx = 0; defIdx < slideDefs.length; defIdx++) {
|
|
83
|
+
const { slideId, text = {} } = slideDefs[defIdx];
|
|
84
|
+
|
|
85
|
+
// Find the source SLIDE node
|
|
86
|
+
const sourceSlide = deck.getNode(slideId);
|
|
87
|
+
if (!sourceSlide) throw new Error(`Slide not found: ${slideId}`);
|
|
88
|
+
|
|
89
|
+
// Find the parent MODULE (if present)
|
|
90
|
+
const parentModuleId = sourceSlide.parentIndex?.guid
|
|
91
|
+
? `${sourceSlide.parentIndex.guid.sessionID}:${sourceSlide.parentIndex.guid.localID}`
|
|
92
|
+
: null;
|
|
93
|
+
const sourceModule = parentModuleId ? deck.getNode(parentModuleId) : null;
|
|
94
|
+
|
|
95
|
+
// Collect all nodes in the subtree to clone (MODULE + SLIDE + all descendants)
|
|
96
|
+
const rootId = sourceModule ? nid(sourceModule) : slideId;
|
|
97
|
+
const subtreeNodes = [];
|
|
98
|
+
deck.walkTree(rootId, (node) => subtreeNodes.push(node));
|
|
99
|
+
|
|
100
|
+
// Build ID remap table: old "s:l" → new { sessionID, localID }
|
|
101
|
+
const idMap = new Map();
|
|
102
|
+
for (const node of subtreeNodes) {
|
|
103
|
+
const oldId = nid(node);
|
|
104
|
+
if (oldId) idMap.set(oldId, { sessionID: SESSION, localID: nextId++ });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Deep-clone each node with remapped IDs
|
|
108
|
+
const clonedNodes = subtreeNodes.map(node => {
|
|
109
|
+
const clone = deepClone(node);
|
|
110
|
+
|
|
111
|
+
// Remap own guid
|
|
112
|
+
const newGuid = idMap.get(nid(node));
|
|
113
|
+
if (newGuid) clone.guid = newGuid;
|
|
114
|
+
|
|
115
|
+
// Remap parentIndex.guid if it's within the subtree
|
|
116
|
+
if (clone.parentIndex?.guid) {
|
|
117
|
+
const pid = `${clone.parentIndex.guid.sessionID}:${clone.parentIndex.guid.localID}`;
|
|
118
|
+
const remapped = idMap.get(pid);
|
|
119
|
+
if (remapped) {
|
|
120
|
+
clone.parentIndex = { ...clone.parentIndex, guid: remapped };
|
|
121
|
+
} else if (pid === slideRowId || pid === parentModuleId) {
|
|
122
|
+
// Root node — attach to SLIDE_ROW
|
|
123
|
+
clone.parentIndex = {
|
|
124
|
+
guid: { sessionID: slideRowNode.guid.sessionID, localID: slideRowNode.guid.localID },
|
|
125
|
+
position: String.fromCharCode(0x21 + (originalSlideIds.length + defIdx)),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
clone.phase = 'CREATED';
|
|
131
|
+
delete clone.slideThumbnailHash;
|
|
132
|
+
delete clone.editInfo;
|
|
133
|
+
delete clone.prototypeInteractions;
|
|
134
|
+
|
|
135
|
+
return clone;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Apply text overrides: find TEXT nodes by name, set characters
|
|
139
|
+
const clonedSlideGuid = idMap.get(slideId);
|
|
140
|
+
const clonedSlideId = clonedSlideGuid
|
|
141
|
+
? `${clonedSlideGuid.sessionID}:${clonedSlideGuid.localID}`
|
|
142
|
+
: null;
|
|
143
|
+
|
|
144
|
+
for (const clone of clonedNodes) {
|
|
145
|
+
// TEXT nodes — matched by node name
|
|
146
|
+
if (clone.type === 'TEXT' && clone.name && text[clone.name] !== undefined) {
|
|
147
|
+
const chars = text[clone.name] || ' ';
|
|
148
|
+
if (!clone.textData) clone.textData = {};
|
|
149
|
+
clone.textData.characters = chars;
|
|
150
|
+
clone.textData.lines = chars.split('\n').map(() => ({
|
|
151
|
+
lineType: 'PLAIN', styleId: 0, indentationLevel: 0,
|
|
152
|
+
sourceDirectionality: 'AUTO', listStartOffset: 0, isFirstLineOfList: false,
|
|
153
|
+
}));
|
|
154
|
+
delete clone.derivedTextData;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// SHAPE_WITH_TEXT nodes — matched by #nodeId key (original ID before remapping)
|
|
158
|
+
if (clone.type === 'SHAPE_WITH_TEXT' && clone.nodeGenerationData?.overrides) {
|
|
159
|
+
// Find original node ID (before remapping) by reverse-looking up from idMap
|
|
160
|
+
const origId = [...idMap.entries()].find(
|
|
161
|
+
([, v]) => v.sessionID === clone.guid.sessionID && v.localID === clone.guid.localID
|
|
162
|
+
)?.[0];
|
|
163
|
+
const key = origId ? `#${origId}` : null;
|
|
164
|
+
if (key && text[key] !== undefined) {
|
|
165
|
+
const chars = text[key] || ' ';
|
|
166
|
+
for (const ov of clone.nodeGenerationData.overrides) {
|
|
167
|
+
if (ov.textData?.characters !== undefined) {
|
|
168
|
+
ov.textData.characters = chars;
|
|
169
|
+
ov.textData.lines = chars.split('\n').map(() => ({
|
|
170
|
+
lineType: 'PLAIN', styleId: 0, indentationLevel: 0,
|
|
171
|
+
sourceDirectionality: 'AUTO', listStartOffset: 0, isFirstLineOfList: false,
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
delete clone.derivedImmutableFrameData;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
deck.message.nodeChanges.push(...clonedNodes);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Rebuild maps so we can walk the original slide subtrees
|
|
184
|
+
deck.rebuildMaps();
|
|
185
|
+
|
|
186
|
+
// Collect all node IDs in original slide subtrees to physically remove from nodeChanges.
|
|
187
|
+
// phase=REMOVED is NOT used here — Figma ignores it for TEXT/FRAME nodes.
|
|
188
|
+
const pruneIds = new Set();
|
|
189
|
+
|
|
190
|
+
function collectSubtree(id) {
|
|
191
|
+
if (pruneIds.has(id)) return;
|
|
192
|
+
pruneIds.add(id);
|
|
193
|
+
for (const child of deck.getChildren(id)) {
|
|
194
|
+
collectSubtree(nid(child));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const id of originalSlideIds) {
|
|
199
|
+
const slide = deck.getNode(id);
|
|
200
|
+
if (!slide) continue;
|
|
201
|
+
|
|
202
|
+
if (slide.parentIndex?.guid) {
|
|
203
|
+
const modId = `${slide.parentIndex.guid.sessionID}:${slide.parentIndex.guid.localID}`;
|
|
204
|
+
const mod = deck.getNode(modId);
|
|
205
|
+
if (mod?.type === 'MODULE') { collectSubtree(modId); continue; }
|
|
206
|
+
}
|
|
207
|
+
collectSubtree(id);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Filter them out entirely — absent nodes render as non-existent in Figma
|
|
211
|
+
deck.message.nodeChanges = deck.message.nodeChanges.filter(n => {
|
|
212
|
+
const id = nid(n);
|
|
213
|
+
return !id || !pruneIds.has(id);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
deck.rebuildMaps();
|
|
217
|
+
return deck.saveDeck(outputPath);
|
|
218
|
+
}
|
package/mcp-server.mjs
CHANGED
|
@@ -7,6 +7,7 @@ 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 { listTemplateLayouts, createFromTemplate } from './lib/template-deck.mjs';
|
|
10
11
|
import { nid, ov, nestedOv, removeNode, parseId, positionChar } from './lib/node-helpers.mjs';
|
|
11
12
|
import { imageOv, hexToHash, hashToHex } from './lib/image-helpers.mjs';
|
|
12
13
|
import { deepClone } from './lib/deep-clone.mjs';
|
|
@@ -336,6 +337,41 @@ server.tool(
|
|
|
336
337
|
}
|
|
337
338
|
);
|
|
338
339
|
|
|
340
|
+
// ── figmatk_list_template_layouts ────────────────────────────────────────
|
|
341
|
+
server.tool(
|
|
342
|
+
'figmatk_list_template_layouts',
|
|
343
|
+
'Inspect a Figma .deck template and return available slide layouts with their text field names. Call this first before figmatk_create_from_template.',
|
|
344
|
+
{
|
|
345
|
+
template: z.string().describe('Path to the .deck template file'),
|
|
346
|
+
},
|
|
347
|
+
async ({ template }) => {
|
|
348
|
+
const layouts = await listTemplateLayouts(template);
|
|
349
|
+
const lines = layouts.map(l => {
|
|
350
|
+
const fields = l.textFields.map(f => ` - "${f.name}" (${f.nodeId}): "${f.preview}"`).join('\n');
|
|
351
|
+
return `Slide "${l.name}" [${l.slideId}]\n${fields || ' (no text fields)'}`;
|
|
352
|
+
});
|
|
353
|
+
return { content: [{ type: 'text', text: lines.join('\n\n') }] };
|
|
354
|
+
}
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
// ── figmatk_create_from_template ─────────────────────────────────────────
|
|
358
|
+
server.tool(
|
|
359
|
+
'figmatk_create_from_template',
|
|
360
|
+
'Create a new Figma Slides deck by cherry-picking layouts from a template .deck file and populating them with content. Preserves all template colors, fonts, and styling.',
|
|
361
|
+
{
|
|
362
|
+
template: z.string().describe('Path to the source .deck template file'),
|
|
363
|
+
output: z.string().describe('Output path for the new .deck file (use /tmp/)'),
|
|
364
|
+
slides: z.array(z.object({
|
|
365
|
+
slideId: z.string().describe('Slide ID from figmatk_list_template_layouts (e.g. "1:74")'),
|
|
366
|
+
text: z.record(z.string()).optional().describe('Map of text field name → value (e.g. { "Title": "My Company", "Body 1": "..." })'),
|
|
367
|
+
})).describe('Ordered list of slides to include, each referencing a template layout'),
|
|
368
|
+
},
|
|
369
|
+
async ({ template, output, slides }) => {
|
|
370
|
+
const bytes = await createFromTemplate(template, output, slides);
|
|
371
|
+
return { content: [{ type: 'text', text: `Created ${output} — ${slides.length} slides (${bytes} bytes). Open in Figma Desktop.` }] };
|
|
372
|
+
}
|
|
373
|
+
);
|
|
374
|
+
|
|
339
375
|
// ── Start server ────────────────────────────────────────────────────────
|
|
340
376
|
const transport = new StdioServerTransport();
|
|
341
377
|
await server.connect(transport);
|
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@ description: >
|
|
|
6
6
|
clone or remove slides, or produce a .deck file for Figma Slides.
|
|
7
7
|
Powered by FigmaTK under the hood.
|
|
8
8
|
metadata:
|
|
9
|
-
version: "0.
|
|
9
|
+
version: "0.3.0"
|
|
10
10
|
---
|
|
11
11
|
|
|
12
12
|
# Figma Slides Creator
|
|
@@ -23,7 +23,8 @@ To let the user view the result: tell them to **open the file in Figma Desktop**
|
|
|
23
23
|
|
|
24
24
|
| Task | Approach |
|
|
25
25
|
|------|----------|
|
|
26
|
-
| Create
|
|
26
|
+
| Create from scratch | **Path A** — `figmatk_create_deck` MCP tool |
|
|
27
|
+
| Create from a `.deck` template | **Path B** — `figmatk_list_template_layouts` + `figmatk_create_from_template` |
|
|
27
28
|
| Edit text or images in an existing deck | `figmatk_update_text`, `figmatk_insert_image` |
|
|
28
29
|
| Clone, remove, or restructure slides | `figmatk_clone_slide`, `figmatk_remove_slide` |
|
|
29
30
|
| Inspect structure or read content | `figmatk_inspect`, `figmatk_list_text` |
|
|
@@ -36,7 +37,45 @@ To let the user view the result: tell them to **open the file in Figma Desktop**
|
|
|
36
37
|
|
|
37
38
|
---
|
|
38
39
|
|
|
39
|
-
## Path
|
|
40
|
+
## Path B — Create from a Template (preferred when user provides a .deck file)
|
|
41
|
+
|
|
42
|
+
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.
|
|
43
|
+
|
|
44
|
+
### Step 1 — Inspect the template
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
figmatk_list_template_layouts("/path/to/template.deck")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Returns a catalog of all available slide layouts. Each entry includes:
|
|
51
|
+
- `slideId` — the ID to reference this layout
|
|
52
|
+
- Text fields — editable TEXT nodes with their names and current content
|
|
53
|
+
- Image placeholders — FRAME nodes with IMAGE fill (these need a real image)
|
|
54
|
+
|
|
55
|
+
**Read the catalog carefully before picking layouts:**
|
|
56
|
+
- Match each slide's purpose to your content (the existing text in the template is a strong hint — e.g. "Use this slide to introduce the big problem" → use for your problem statement)
|
|
57
|
+
- Slides with image placeholders need an appropriate image — the surrounding text should describe what's shown in that image
|
|
58
|
+
- Slides with `SHAPE_WITH_TEXT` pill labels (MONTH XX YEAR, TAGLINE, CONFIDENTIAL) cannot be changed programmatically — tell the user to update those in Figma
|
|
59
|
+
|
|
60
|
+
### Step 2 — Create the deck
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
figmatk_create_from_template({
|
|
64
|
+
template: "/path/to/template.deck",
|
|
65
|
+
output: "/tmp/my-deck.deck",
|
|
66
|
+
slides: [
|
|
67
|
+
{ slideId: "1:74", text: { "Title": "My Company" } },
|
|
68
|
+
{ slideId: "1:112", text: { "Header 1": "The problem.", "Body 1": "Description here." } },
|
|
69
|
+
{ slideId: "1:643", text: { "Thank you": "Thank you!" } }
|
|
70
|
+
]
|
|
71
|
+
})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Only pass text fields that exist in the layout's catalog — extra fields are silently ignored.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Path A — Create from Scratch (MCP tool — no template)
|
|
40
79
|
|
|
41
80
|
**Always use this path.** No npm install, no scripts, no workspace setup.
|
|
42
81
|
|
|
@@ -289,7 +328,7 @@ Every slide needs at least **one visual element** — shape, image, SVG, or tabl
|
|
|
289
328
|
- Centre body text
|
|
290
329
|
- Use accent lines under slide titles (hallmark of AI-generated slides)
|
|
291
330
|
- Text-only slides
|
|
292
|
-
- Low-contrast text against background
|
|
331
|
+
- 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
332
|
- Skip the closing slide — it makes the deck feel unfinished
|
|
294
333
|
- Put long paragraphs in body/caption fields — text overflows the container
|
|
295
334
|
|