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.
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "name": "figmatk",
15
15
  "description": "Swiss Army Knife for Figma Files (.deck)",
16
- "version": "0.2.6",
16
+ "version": "0.3.0",
17
17
  "author": {
18
18
  "name": "FigmaTK Contributors"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figmatk",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
4
4
  "description": "Create and edit Figma Slides .deck files programmatically — no Figma API required",
5
5
  "author": {
6
6
  "name": "FigmaTK Contributors"
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figmatk",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
4
4
  "description": "Figma Toolkit — Swiss-army knife CLI for Figma .deck and .fig files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.2.6"
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 a new deck from scratch | **`figmatk_create_deck` MCP tool** — no npm install needed |
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 A — Create from Scratch (MCP tool preferred)
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