figmatk 0.3.0 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +49 -14
  4. package/cli.mjs +2 -0
  5. package/commands/render.mjs +56 -0
  6. package/lib/fig-deck.mjs +4 -4
  7. package/lib/rasterizer/deck-rasterizer.mjs +228 -0
  8. package/lib/rasterizer/download-font.mjs +57 -0
  9. package/lib/rasterizer/font-resolver.mjs +602 -0
  10. package/lib/rasterizer/fonts/DarkerGrotesque-400.ttf +0 -0
  11. package/lib/rasterizer/fonts/DarkerGrotesque-500.ttf +0 -0
  12. package/lib/rasterizer/fonts/DarkerGrotesque-600.ttf +0 -0
  13. package/lib/rasterizer/fonts/Inter-Regular.ttf +0 -0
  14. package/lib/rasterizer/fonts/Inter-Variable.ttf +0 -0
  15. package/lib/rasterizer/fonts/Inter.var.woff2 +0 -0
  16. package/lib/rasterizer/fonts/darker-grotesque-patched-400-normal.woff2 +0 -0
  17. package/lib/rasterizer/fonts/darker-grotesque-patched-500-normal.woff2 +0 -0
  18. package/lib/rasterizer/fonts/darker-grotesque-patched-600-normal.woff2 +0 -0
  19. package/lib/rasterizer/fonts/darker-grotesque-patched-700-normal.woff2 +0 -0
  20. package/lib/rasterizer/fonts/inter-v3-400-italic.woff2 +0 -0
  21. package/lib/rasterizer/fonts/inter-v3-400-normal.woff2 +0 -0
  22. package/lib/rasterizer/fonts/inter-v3-500-normal.woff2 +0 -0
  23. package/lib/rasterizer/fonts/inter-v3-600-normal.woff2 +0 -0
  24. package/lib/rasterizer/fonts/inter-v3-700-italic.woff2 +0 -0
  25. package/lib/rasterizer/fonts/inter-v3-700-normal.woff2 +0 -0
  26. package/lib/rasterizer/fonts/inter-v3-meta.json +6 -0
  27. package/lib/rasterizer/render-report-lib.mjs +127 -0
  28. package/lib/rasterizer/render-report.mjs +25 -0
  29. package/lib/rasterizer/svg-builder.mjs +571 -0
  30. package/lib/rasterizer/test-render.mjs +63 -0
  31. package/lib/template-deck.mjs +573 -148
  32. package/manifest.json +21 -0
  33. package/mcp-server.mjs +184 -20
  34. package/package.json +17 -2
  35. package/skills/figma-slides-creator/SKILL.md +79 -172
  36. package/skills/figma-template-builder/SKILL.md +158 -0
package/manifest.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "manifest_version": "0.2",
3
+ "name": "figmatk",
4
+ "version": "0.3.3",
5
+ "description": "Create and edit Figma Slides .deck files programmatically - no Figma API required",
6
+ "author": {
7
+ "name": "FigmaTK Contributors"
8
+ },
9
+ "server": {
10
+ "type": "node",
11
+ "entry_point": "mcp-server.mjs",
12
+ "mcp_config": {
13
+ "command": "node",
14
+ "args": [
15
+ "${__dirname}/mcp-server.mjs"
16
+ ],
17
+ "env": {}
18
+ }
19
+ },
20
+ "license": "MIT"
21
+ }
package/mcp-server.mjs CHANGED
@@ -5,16 +5,26 @@
5
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
7
  import { z } from 'zod';
8
+ import packageJson from './package.json' with { type: 'json' };
8
9
  import { FigDeck } from './lib/fig-deck.mjs';
9
10
  import { Deck } from './lib/api.mjs';
10
- import { listTemplateLayouts, createFromTemplate } from './lib/template-deck.mjs';
11
- import { nid, ov, nestedOv, removeNode, parseId, positionChar } from './lib/node-helpers.mjs';
12
- import { imageOv, hexToHash, hashToHex } from './lib/image-helpers.mjs';
11
+ import { slideToSvg } from './lib/rasterizer/svg-builder.mjs';
12
+ import { svgToPng } from './lib/rasterizer/deck-rasterizer.mjs';
13
+ import { resolveFonts } from './lib/rasterizer/font-resolver.mjs';
14
+ import {
15
+ annotateTemplateLayout,
16
+ createDraftTemplate,
17
+ createFromTemplate,
18
+ listTemplateLayouts,
19
+ publishTemplateDraft,
20
+ } from './lib/template-deck.mjs';
21
+ import { nid, ov, removeNode } from './lib/node-helpers.mjs';
22
+ import { imageOv, hashToHex } from './lib/image-helpers.mjs';
13
23
  import { deepClone } from './lib/deep-clone.mjs';
14
24
 
15
25
  const server = new McpServer({
16
26
  name: 'figmatk',
17
- version: '0.0.3',
27
+ version: packageJson.version,
18
28
  });
19
29
 
20
30
  // ── inspect ─────────────────────────────────────────────────────────────
@@ -46,7 +56,7 @@ server.tool(
46
56
  // ── list-text ───────────────────────────────────────────────────────────
47
57
  server.tool(
48
58
  'figmatk_list_text',
49
- 'List all text and image content per slide in a .deck file',
59
+ 'List visible text and image content per slide in a .deck file, including direct slide nodes and instance overrides.',
50
60
  { path: z.string().describe('Path to .deck or .fig file') },
51
61
  async ({ path }) => {
52
62
  const deck = await FigDeck.fromDeckFile(path);
@@ -56,18 +66,41 @@ server.tool(
56
66
  if (slide.phase === 'REMOVED') continue;
57
67
  const id = nid(slide);
58
68
  lines.push(`\n── Slide ${id} "${slide.name || ''}" ──`);
69
+
70
+ const directLines = [];
71
+ deck.walkTree(id, (node, depth) => {
72
+ if (depth === 0 || node.phase === 'REMOVED') return;
73
+ if (node.type === 'TEXT' && node.textData?.characters) {
74
+ directLines.push(` [text-node] ${nid(node)} "${node.name || ''}": ${node.textData.characters.substring(0, 120)}`);
75
+ }
76
+ if (node.type === 'SHAPE_WITH_TEXT' && node.nodeGenerationData?.overrides) {
77
+ for (const override of node.nodeGenerationData.overrides) {
78
+ if (override.textData?.characters) {
79
+ directLines.push(` [shape-text] ${nid(node)} "${node.name || ''}": ${override.textData.characters.substring(0, 120)}`);
80
+ break;
81
+ }
82
+ }
83
+ }
84
+ const imageFill = node.fillPaints?.find(p => p.type === 'IMAGE' && p.image?.hash);
85
+ if (imageFill) {
86
+ directLines.push(` [image-node] ${nid(node)} "${node.name || ''}": ${hashToHex(imageFill.image.hash)}`);
87
+ }
88
+ });
89
+
90
+ lines.push(...directLines);
91
+
59
92
  const inst = deck.getSlideInstance(id);
60
93
  if (!inst?.symbolData?.symbolOverrides) continue;
61
94
  for (const ov of inst.symbolData.symbolOverrides) {
62
95
  const key = ov.guidPath?.guids?.[0];
63
96
  const keyStr = key ? `${key.sessionID}:${key.localID}` : '?';
64
97
  if (ov.textData?.characters) {
65
- lines.push(` [text] ${keyStr}: ${ov.textData.characters.substring(0, 120)}`);
98
+ lines.push(` [text-override] ${keyStr}: ${ov.textData.characters.substring(0, 120)}`);
66
99
  }
67
100
  if (ov.fillPaints?.length) {
68
101
  for (const p of ov.fillPaints) {
69
102
  if (p.image?.hash) {
70
- lines.push(` [image] ${keyStr}: ${hashToHex(p.image.hash)}`);
103
+ lines.push(` [image-override] ${keyStr}: ${hashToHex(p.image.hash)}`);
71
104
  }
72
105
  }
73
106
  }
@@ -93,13 +126,13 @@ server.tool(
93
126
  function walkChildren(nodeId, depth) {
94
127
  const node = deck.getNode(nodeId);
95
128
  if (!node || node.phase === 'REMOVED') return;
96
- const cid = nid(node);
129
+ const key = node.overrideKey ? `${node.overrideKey.sessionID}:${node.overrideKey.localID}` : null;
97
130
  const type = node.type || '?';
98
131
  const name = node.name || '';
99
- if (type === 'TEXT' || (node.fillPaints?.some(p => p.type === 'IMAGE'))) {
100
- lines.push(` ${' '.repeat(depth)}${type} ${cid} "${name}"`);
132
+ if (key && (type === 'TEXT' || node.fillPaints?.some(p => p.type === 'IMAGE'))) {
133
+ lines.push(` ${' '.repeat(depth)}${type} ${key} "${name}"`);
101
134
  }
102
- const kids = deck.childrenMap.get(cid) || [];
135
+ const kids = deck.childrenMap.get(nid(node)) || [];
103
136
  for (const kid of kids) walkChildren(nid(kid), depth + 1);
104
137
  }
105
138
  for (const child of children) walkChildren(nid(child), 0);
@@ -127,7 +160,18 @@ server.tool(
127
160
 
128
161
  for (const [key, text] of Object.entries(overrides)) {
129
162
  const [s, l] = key.split(':').map(Number);
130
- inst.symbolData.symbolOverrides.push(ov({ sessionID: s, localID: l }, text));
163
+ const nextOverride = ov({ sessionID: s, localID: l }, text);
164
+ const existingIdx = inst.symbolData.symbolOverrides.findIndex(entry =>
165
+ entry.guidPath?.guids?.length >= 1 &&
166
+ entry.guidPath.guids[0].sessionID === s &&
167
+ entry.guidPath.guids[0].localID === l &&
168
+ entry.textData
169
+ );
170
+ if (existingIdx >= 0) {
171
+ inst.symbolData.symbolOverrides.splice(existingIdx, 1, nextOverride);
172
+ } else {
173
+ inst.symbolData.symbolOverrides.push(nextOverride);
174
+ }
131
175
  }
132
176
 
133
177
  const bytes = await deck.saveDeck(output);
@@ -158,9 +202,18 @@ server.tool(
158
202
  if (!inst.symbolData.symbolOverrides) inst.symbolData.symbolOverrides = [];
159
203
 
160
204
  const [s, l] = targetKey.split(':').map(Number);
161
- inst.symbolData.symbolOverrides.push(
162
- imageOv({ sessionID: s, localID: l }, imageHash, thumbHash, width, height)
205
+ const nextOverride = imageOv({ sessionID: s, localID: l }, imageHash, thumbHash, width, height);
206
+ const existingIdx = inst.symbolData.symbolOverrides.findIndex(entry =>
207
+ entry.guidPath?.guids?.length >= 1 &&
208
+ entry.guidPath.guids[0].sessionID === s &&
209
+ entry.guidPath.guids[0].localID === l &&
210
+ entry.fillPaints
163
211
  );
212
+ if (existingIdx >= 0) {
213
+ inst.symbolData.symbolOverrides.splice(existingIdx, 1, nextOverride);
214
+ } else {
215
+ inst.symbolData.symbolOverrides.push(nextOverride);
216
+ }
164
217
 
165
218
  const opts = imagesDir ? { imagesDir } : {};
166
219
  const bytes = await deck.saveDeck(output, opts);
@@ -338,17 +391,70 @@ server.tool(
338
391
  );
339
392
 
340
393
  // ── figmatk_list_template_layouts ────────────────────────────────────────
394
+ server.tool(
395
+ 'figmatk_create_template_draft',
396
+ 'Create a new draft template deck. Draft templates are normal slide decks; later annotate slots and publish-wrap them into module-backed layouts.',
397
+ {
398
+ output: z.string().describe('Output path for the draft template .deck file'),
399
+ title: z.string().describe('Template deck title'),
400
+ layouts: z.array(z.string()).optional().describe('Optional ordered list of layout names to create, e.g. ["cover", "agenda", "section"]'),
401
+ },
402
+ async ({ output, title, layouts }) => {
403
+ const bytes = await createDraftTemplate(output, { title, layouts });
404
+ return { content: [{ type: 'text', text: `Created draft template ${output} (${bytes} bytes). Use figmatk_annotate_template_layout to mark layout and slot names.` }] };
405
+ }
406
+ );
407
+
408
+ server.tool(
409
+ 'figmatk_annotate_template_layout',
410
+ '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.',
411
+ {
412
+ path: z.string().describe('Path to the source .deck file'),
413
+ output: z.string().describe('Output path for the updated .deck file'),
414
+ slideId: z.string().describe('Slide node ID to annotate'),
415
+ layoutName: z.string().optional().describe('Logical layout name without the layout: prefix, e.g. "cover"'),
416
+ textSlots: z.record(z.string()).optional().describe('Map of nodeId -> text slot name, e.g. {"1:120": "title"}'),
417
+ imageSlots: z.record(z.string()).optional().describe('Map of nodeId -> image slot name, e.g. {"1:144": "hero_image"}'),
418
+ fixedImages: z.record(z.string()).optional().describe('Map of nodeId -> fixed image label for decorative/sample content'),
419
+ },
420
+ async ({ path, output, slideId, layoutName, textSlots, imageSlots, fixedImages }) => {
421
+ const bytes = await annotateTemplateLayout(path, output, { slideId, layoutName, textSlots, imageSlots, fixedImages });
422
+ return { content: [{ type: 'text', text: `Annotated slide ${slideId}. Saved ${output} (${bytes} bytes).` }] };
423
+ }
424
+ );
425
+
426
+ server.tool(
427
+ 'figmatk_publish_template_draft',
428
+ 'Wrap draft template slides in publish-like MODULE nodes while preserving the slide subtree and internal canvas assets.',
429
+ {
430
+ path: z.string().describe('Path to the draft template .deck file'),
431
+ output: z.string().describe('Output path for the wrapped .deck file'),
432
+ slideIds: z.array(z.string()).optional().describe('Optional list of draft slide IDs to wrap. Defaults to every draft layout on the main canvas.'),
433
+ },
434
+ async ({ path, output, slideIds }) => {
435
+ const bytes = await publishTemplateDraft(path, output, { slideIds });
436
+ return { content: [{ type: 'text', text: `Publish-wrapped draft template to ${output} (${bytes} bytes).` }] };
437
+ }
438
+ );
439
+
341
440
  server.tool(
342
441
  '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.',
442
+ 'Inspect a template or draft template .deck file and return a layout library catalog with explicit text/image slot metadata. Use this to inventory candidate layouts, classify what each layout is for, and choose a subset before calling figmatk_create_from_template. Do not edit, remove, or reorder the source template as a way to build the output deck.',
344
443
  {
345
444
  template: z.string().describe('Path to the .deck template file'),
346
445
  },
347
446
  async ({ template }) => {
348
447
  const layouts = await listTemplateLayouts(template);
349
448
  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)'}`;
449
+ const textSlots = l.textFields.map(f => ` - ${f.name} (${f.nodeId}, ${f.source}): "${f.preview}"`).join('\n');
450
+ const imageSlots = l.imagePlaceholders.map(f => ` - ${f.name} (${f.nodeId}, ${f.source}, ${f.width}x${f.height})${f.hasCurrentImage ? ' [image]' : ''}`).join('\n');
451
+ return [
452
+ `Layout "${l.name}" [${l.slideId}]`,
453
+ ` state: ${l.state}${l.moduleId ? `, module ${l.moduleId}` : ''}, row ${l.rowId}`,
454
+ ` explicit slots: ${l.hasExplicitSlotMetadata ? 'yes' : 'no'}`,
455
+ textSlots ? ` text slots:\n${textSlots}` : ' text slots: (none)',
456
+ imageSlots ? ` image slots:\n${imageSlots}` : ' image slots: (none)',
457
+ ].join('\n');
352
458
  });
353
459
  return { content: [{ type: 'text', text: lines.join('\n\n') }] };
354
460
  }
@@ -357,14 +463,15 @@ server.tool(
357
463
  // ── figmatk_create_from_template ─────────────────────────────────────────
358
464
  server.tool(
359
465
  '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.',
466
+ 'Create a new Figma Slides deck by selecting any ordered subset of layouts from a draft, published, or publish-like template .deck file and populating explicit text/image slots. The slides array defines the output order. This clones the chosen layouts into a new deck; do not remove, reorder, or mutate the source template to build the result. Works with flat-frame template slides as well as module-backed layouts, and preserves colors, fonts, internal assets, and special nodes.',
361
467
  {
362
468
  template: z.string().describe('Path to the source .deck template file'),
363
469
  output: z.string().describe('Output path for the new .deck file (use /tmp/)'),
364
470
  slides: z.array(z.object({
365
471
  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'),
472
+ text: z.record(z.string()).optional().describe('Map of text slot/name/nodeId -> value (e.g. { "title": "My Company" })'),
473
+ images: z.record(z.string()).optional().describe('Map of image slot/name/nodeId -> absolute image path (e.g. { "hero_image": "/tmp/photo.jpg" })'),
474
+ })).describe('Ordered subset of template layouts to include in the output deck. The array order becomes the output deck order, regardless of the template\'s original order.'),
368
475
  },
369
476
  async ({ template, output, slides }) => {
370
477
  const bytes = await createFromTemplate(template, output, slides);
@@ -372,6 +479,63 @@ server.tool(
372
479
  }
373
480
  );
374
481
 
482
+ // ── render-slide ───────────────────────────────────────────────────────
483
+ server.tool(
484
+ 'figmatk_render_slide',
485
+ 'Render a slide from a .deck file to an image. Without output path, returns inline WebP for visual QA. With output path, saves full PNG. Default is 1920×1080; use width (pixels) or scale (e.g. "50%") to resize.',
486
+ {
487
+ path: z.string().describe('Path to .deck file'),
488
+ slide: z.number().int().min(1).describe('Slide number (1-based)'),
489
+ output: z.string().optional().describe('Optional output path to save the PNG file (full resolution)'),
490
+ width: z.number().int().optional().describe('Output width in pixels (height scales proportionally)'),
491
+ scale: z.string().optional().describe('Scale as percentage, e.g. "50%" or "25%"'),
492
+ },
493
+ async ({ path, slide, output, width, scale }) => {
494
+ const deck = await FigDeck.fromDeckFile(path);
495
+ await resolveFonts(deck, { quiet: true });
496
+ const slides = deck.getActiveSlides();
497
+
498
+ if (slide > slides.length) {
499
+ return { content: [{ type: 'text', text: `Slide ${slide} does not exist — deck has ${slides.length} slides` }] };
500
+ }
501
+
502
+ const svg = slideToSvg(deck, slides[slide - 1]);
503
+
504
+ // Build render options
505
+ const renderOpts = {};
506
+ if (width) {
507
+ renderOpts.width = width;
508
+ } else if (scale) {
509
+ const pct = parseFloat(scale.replace('%', ''));
510
+ if (!isNaN(pct) && pct > 0) renderOpts.scale = pct / 100;
511
+ }
512
+
513
+ const png = await svgToPng(svg, renderOpts);
514
+ const buf = Buffer.from(png);
515
+
516
+ if (output) {
517
+ const { writeFileSync } = await import('fs');
518
+ writeFileSync(output, buf);
519
+ return { content: [{ type: 'text', text: `Rendered slide ${slide} → ${output} (${buf.length} bytes)` }] };
520
+ }
521
+
522
+ // For inline display: convert to WebP (much smaller) via sharp
523
+ // Default to width=800 if no size specified to stay under MCP 1MB limit
524
+ const sharp = (await import('sharp')).default;
525
+ let img = sharp(buf);
526
+ if (!width && !scale) img = img.resize(800);
527
+ const webp = await img.webp({ quality: 80 }).toBuffer();
528
+
529
+ return {
530
+ content: [{
531
+ type: 'image',
532
+ data: webp.toString('base64'),
533
+ mimeType: 'image/webp',
534
+ }],
535
+ };
536
+ }
537
+ );
538
+
375
539
  // ── Start server ────────────────────────────────────────────────────────
376
540
  const transport = new StdioServerTransport();
377
541
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figmatk",
3
- "version": "0.3.0",
3
+ "version": "0.3.7",
4
4
  "description": "Figma Toolkit — Swiss-army knife CLI for Figma .deck and .fig files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,7 @@
17
17
  "files": [
18
18
  "cli.mjs",
19
19
  "mcp-server.mjs",
20
+ "manifest.json",
20
21
  "lib/",
21
22
  "commands/",
22
23
  "skills/",
@@ -26,7 +27,12 @@
26
27
  "README.md"
27
28
  ],
28
29
  "scripts": {
29
- "start": "node cli.mjs"
30
+ "start": "node cli.mjs",
31
+ "pack": "node scripts/pack.mjs",
32
+ "release": "node scripts/release.mjs",
33
+ "validate:extension": "mcpb validate manifest.json",
34
+ "test": "vitest run",
35
+ "test:watch": "vitest"
30
36
  },
31
37
  "engines": {
32
38
  "node": ">=18"
@@ -42,13 +48,22 @@
42
48
  "author": "rcoenen",
43
49
  "dependencies": {
44
50
  "@modelcontextprotocol/sdk": "^1.27.1",
51
+ "@resvg/resvg-wasm": "^2.6.2",
45
52
  "archiver": "^7.0.1",
46
53
  "fzstd": "^0.1.1",
47
54
  "kiwi-schema": "^0.5.0",
48
55
  "pako": "^2.1.0",
49
56
  "sharp": "^0.34.5",
57
+ "ssim.js": "^3.5.0",
50
58
  "zstd-codec": "^0.1.5"
51
59
  },
60
+ "devDependencies": {
61
+ "@anthropic-ai/mcpb": "^2.1.2",
62
+ "@fontsource/darker-grotesque": "^5.2.8",
63
+ "@fontsource/inter": "^5.2.8",
64
+ "@fontsource/irish-grover": "^5.2.7",
65
+ "vitest": "^4.1.0"
66
+ },
52
67
  "keywords": [
53
68
  "figma",
54
69
  "deck",