@toghen/figma 0.0.0-stage → 0.1.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.
Files changed (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -2
  3. package/cli/bin.mjs +19 -0
  4. package/cli/bridge.ts +243 -0
  5. package/cli/check.ts +62 -0
  6. package/cli/figcraft.ts +764 -0
  7. package/cli/figcraftApply.ts +1058 -0
  8. package/cli/figcraftFile.ts +476 -0
  9. package/cli/figcraftLibrary.ts +147 -0
  10. package/cli/figcraftRecord.ts +121 -0
  11. package/cli/figcraftScene.ts +661 -0
  12. package/cli/main.ts +218 -0
  13. package/cli/members.ts +65 -0
  14. package/cli/run.ts +756 -0
  15. package/component-files.md +665 -0
  16. package/dist/code.js +24147 -0
  17. package/dist/manifest.json +23 -0
  18. package/dist/ui.html +6918 -0
  19. package/guide.md +985 -0
  20. package/main/code.ts +50 -0
  21. package/main/components/apply.ts +704 -0
  22. package/main/components/endOfRun.ts +168 -0
  23. package/main/components/errors.ts +17 -0
  24. package/main/components/fieldWriter.ts +497 -0
  25. package/main/components/file.ts +263 -0
  26. package/main/components/receipt.ts +228 -0
  27. package/main/components/resolve.ts +1193 -0
  28. package/main/components/run.ts +273 -0
  29. package/main/components/snapshot.ts +341 -0
  30. package/main/components/stamp.ts +312 -0
  31. package/main/components/update.ts +465 -0
  32. package/main/dispatcher.ts +33 -0
  33. package/main/engines/batchRunner.ts +200 -0
  34. package/main/engines/ioAdapter.ts +132 -0
  35. package/main/engines/operationExecutor.ts +512 -0
  36. package/main/engines/propertyMapper.ts +221 -0
  37. package/main/engines/queryEngine.ts +45 -0
  38. package/main/engines/styleSyncEngine.ts +444 -0
  39. package/main/engines/variableSyncEngine.ts +561 -0
  40. package/main/handlers/auditSelection.ts +60 -0
  41. package/main/handlers/boundLayers.ts +51 -0
  42. package/main/handlers/cancelOperation.ts +18 -0
  43. package/main/handlers/executeApplyBindings.ts +69 -0
  44. package/main/handlers/executeScaffold.ts +59 -0
  45. package/main/handlers/executeStyleSync.ts +121 -0
  46. package/main/handlers/executeVariableSync.ts +82 -0
  47. package/main/handlers/exportDocument.ts +114 -0
  48. package/main/handlers/getSelection.ts +43 -0
  49. package/main/handlers/index.ts +30 -0
  50. package/main/handlers/inspectCorrespondence.ts +172 -0
  51. package/main/handlers/loadConventions.ts +86 -0
  52. package/main/handlers/loadDocument.ts +253 -0
  53. package/main/handlers/lock.ts +270 -0
  54. package/main/handlers/planApply.ts +157 -0
  55. package/main/handlers/planApplyBindings.ts +79 -0
  56. package/main/handlers/planScaffold.ts +84 -0
  57. package/main/handlers/planStyleSync.ts +49 -0
  58. package/main/handlers/planVariableSync.ts +49 -0
  59. package/main/handlers/readSelectionBindings.ts +160 -0
  60. package/main/handlers/resizeUi.ts +105 -0
  61. package/main/handlers/runComponent.ts +65 -0
  62. package/main/handlers/templates.ts +462 -0
  63. package/main/handlers/tokenGraph.ts +122 -0
  64. package/main/runtimeState.ts +66 -0
  65. package/main/utils/abortController.ts +11 -0
  66. package/main/utils/boundVariables.ts +84 -0
  67. package/main/utils/buildPayloads.ts +85 -0
  68. package/main/utils/correspondence.ts +658 -0
  69. package/main/utils/designSource.ts +122 -0
  70. package/main/utils/figmaCollectionIndex.ts +120 -0
  71. package/main/utils/figmaStyleAdapter.ts +268 -0
  72. package/main/utils/figmaTokenAdapter.ts +255 -0
  73. package/main/utils/flattenDesignSystem.ts +38 -0
  74. package/main/utils/folds.ts +203 -0
  75. package/main/utils/fontStack.ts +57 -0
  76. package/main/utils/lists.ts +4 -0
  77. package/main/utils/lockStore.ts +72 -0
  78. package/main/utils/moves.ts +67 -0
  79. package/main/utils/pageRoots.ts +58 -0
  80. package/main/utils/planSymbols.ts +96 -0
  81. package/main/utils/purpose.ts +351 -0
  82. package/main/utils/recentStore.ts +166 -0
  83. package/main/utils/resolverModes.ts +271 -0
  84. package/main/utils/rootRecord.ts +137 -0
  85. package/main/utils/shardedData.ts +142 -0
  86. package/main/utils/standing.ts +106 -0
  87. package/main/utils/styleTarget.ts +51 -0
  88. package/main/utils/sum.ts +612 -0
  89. package/main/utils/symbolicIdRegistry.ts +26 -0
  90. package/main/utils/syncState.ts +133 -0
  91. package/main/utils/templateStore.ts +73 -0
  92. package/main/utils/tokenPath.ts +28 -0
  93. package/main/utils/tokenStamps.ts +91 -0
  94. package/manifest.json +15 -0
  95. package/package.json +64 -4
  96. package/reference.md +1609 -0
  97. package/shared/components/artboard.ts +126 -0
  98. package/shared/components/axes.ts +420 -0
  99. package/shared/components/bindings.ts +64 -0
  100. package/shared/components/blueprint.ts +248 -0
  101. package/shared/components/check.ts +705 -0
  102. package/shared/components/diff.ts +668 -0
  103. package/shared/components/fields.ts +404 -0
  104. package/shared/components/groups.ts +306 -0
  105. package/shared/components/index.ts +140 -0
  106. package/shared/components/members.ts +280 -0
  107. package/shared/components/model.ts +273 -0
  108. package/shared/components/order.ts +53 -0
  109. package/shared/components/read.ts +480 -0
  110. package/shared/components/stack.ts +124 -0
  111. package/shared/components/steps.ts +186 -0
  112. package/shared/components/validate.ts +548 -0
  113. package/shared/conventions.ts +28 -0
  114. package/shared/inputs.ts +40 -0
  115. package/shared/operations.ts +233 -0
  116. package/shared/pendingNames.ts +39 -0
  117. package/shared/protocol.ts +588 -0
  118. package/shared/tokenTypeMap.ts +21 -0
@@ -0,0 +1,764 @@
1
+ /**
2
+ * The FigCraft target: `toghen-figma variables <resolver> --figcraft <port>` syncs into a file open in FigCraft.
3
+ *
4
+ * FigCraft runs no Figma plugin, so there is nothing to pair with. It listens on a loopback control socket instead
5
+ * (`figcraft --control <port>`), newline-delimited JSON, and runs its own commands there. This module stands in for
6
+ * the plugin: it runs the plugin's own handlers in this process, with a `figma` that answers their reads from what
7
+ * FigCraft lists, and writes each plan operation as FigCraft commands (`figcraftFile.ts`). The run's steps are the
8
+ * Figma run's (`run.ts`), so the plans, their checks and their report are the same (specs.md, *The command line*, *FigCraft*).
9
+ *
10
+ * FigCraft acts on the file a window shows. The run pins the file shown when it starts, and every request carries that
11
+ * window and switches it back to that file inside the same batch, so a write lands there even if the person looked at
12
+ * another tab meanwhile. A request reads the tabs back in the same batch, and the run stops if the file moved.
13
+ *
14
+ * With no app, `--figcraft-file <path.figcraft>` runs against a file headless: `figcraft-cli mcp` as a child process,
15
+ * spoken to in MCP over its stdio (`headless`). The same commands go through it, so everything above the transport is
16
+ * unchanged: the session opens the file, has no window, and saves the file once the run has completed.
17
+ */
18
+
19
+ import { spawn } from 'node:child_process';
20
+ import { existsSync } from 'node:fs';
21
+ import { basename } from 'node:path';
22
+ import { connect as connectSocket, type Socket } from 'node:net';
23
+ import * as handlers from '../main/handlers/index.js';
24
+ import { labelForOperation, writeOperationsWith } from '../main/engines/operationExecutor.js';
25
+ import { writeComponentsWith } from '../main/components/run.js';
26
+ import { pinLibraryModes } from '../main/components/resolve.js';
27
+ import type { Operation } from '../shared/operations.js';
28
+ import type { Command, Response } from '../shared/protocol.js';
29
+ import type { Bridge } from './bridge.js';
30
+ import { figmaView, translate, type FcListing, type FcStyle, type FigmaView, type Step } from './figcraftFile.js';
31
+ import {
32
+ attach,
33
+ guard,
34
+ readExtras,
35
+ readRecord,
36
+ RECORD_KEY,
37
+ RECORD_PAGE,
38
+ recordText,
39
+ type Extras,
40
+ type StampRecord,
41
+ } from './figcraftRecord.js';
42
+ import { figcraftWriter } from './figcraftApply.js';
43
+ import { splitKey, type FcLibrary } from './figcraftLibrary.js';
44
+ import { clearWrites, hasWrites, noWrites, sceneOf, writeSteps, type FcNode, type Scene } from './figcraftScene.js';
45
+
46
+ /** One connection to FigCraft, its control socket or a headless session: one request, one reply, in order. */
47
+ export interface Control {
48
+ call(method: string, params: Record<string, unknown>): Promise<unknown>;
49
+ /** Opens a file on disk in a tab of its own, or shows it where it is open already; answers nothing. */
50
+ open(path: string, window?: number): Promise<void>;
51
+ /** A headless session saves its file, and answers the path saved; the app's file is the person's to save. */
52
+ save?(): Promise<string>;
53
+ /** A headless session's `figcraft-cli` version. */
54
+ version?: string;
55
+ close(): void;
56
+ }
57
+
58
+ export function connect(port: number): Promise<Control> {
59
+ return new Promise((resolve, reject) => {
60
+ const socket: Socket = connectSocket(port, '127.0.0.1');
61
+ const waiting: Array<{ resolve(value: unknown): void; reject(error: Error): void }> = [];
62
+ let buffer = '';
63
+ let next = 1;
64
+ socket.setEncoding('utf8');
65
+ socket.once('error', (error) =>
66
+ reject(new Error(`Nothing answered on 127.0.0.1:${port}: ${error.message}. Start FigCraft with --control ${port}.`)),
67
+ );
68
+ socket.on('data', (chunk: string) => {
69
+ buffer += chunk;
70
+ for (let end = buffer.indexOf('\n'); end >= 0; end = buffer.indexOf('\n')) {
71
+ const reply = JSON.parse(buffer.slice(0, end)) as { ok: boolean; result?: unknown; error?: string };
72
+ buffer = buffer.slice(end + 1);
73
+ const pending = waiting.shift();
74
+ if (reply.ok) pending?.resolve(reply.result);
75
+ else pending?.reject(new Error(reply.error ?? 'FigCraft refused the request'));
76
+ }
77
+ });
78
+ socket.on('close', () => {
79
+ for (const pending of waiting.splice(0)) pending.reject(new Error('FigCraft closed the connection'));
80
+ });
81
+ const call = (method: string, params: Record<string, unknown>): Promise<unknown> =>
82
+ new Promise((done, fail) => {
83
+ waiting.push({ resolve: done, reject: fail });
84
+ socket.write(`${JSON.stringify({ id: next++, method, params })}\n`);
85
+ });
86
+ socket.once('connect', () => {
87
+ socket.on('error', () => {});
88
+ resolve({
89
+ call,
90
+ open: async (path, window) =>
91
+ void (await call('engine.execute', { command: 'file.open', params: { path }, ...(window !== undefined ? { window } : {}) })),
92
+ close: () => socket.end(),
93
+ });
94
+ });
95
+ });
96
+ }
97
+
98
+ /** The oldest `figcraft-cli` a headless run takes: its `node.get` reports layer data, which holds the stamps, from 0.8.0. */
99
+ export const HEADLESS_SINCE = '0.8.0';
100
+
101
+ /** Whether version `a` comes before `b`, both `major.minor.patch`. */
102
+ const before = (a: string, b: string): boolean => {
103
+ const [x, y] = [a, b].map((version) => version.split(/[.+-]/).slice(0, 3).map(Number));
104
+ const at = x.findIndex((part, i) => part !== y[i]);
105
+ return at >= 0 && !(x[at] > y[at]);
106
+ };
107
+
108
+ /**
109
+ * A headless FigCraft session on `file`: `figcraft-cli mcp` (`FIGCRAFT_CLI`, else the one on the PATH) as a child
110
+ * process, MCP's JSON-RPC over its stdio. `engine.execute` goes as the `run_command` tool, which runs the same command
111
+ * registry the socket does; there is no window, so nothing else is offered. The file is opened when it exists; when it
112
+ * does not, the session's untitled file is the one written, and saving gives it the path.
113
+ */
114
+ export function headless(file: string, cli = process.env.FIGCRAFT_CLI || 'figcraft-cli'): Promise<Control> {
115
+ const child = spawn(cli, ['mcp'], { stdio: ['pipe', 'pipe', 'pipe'] });
116
+ const waiting = new Map<number, { resolve(value: unknown): void; reject(error: Error): void }>();
117
+ let buffer = '';
118
+ let said = '';
119
+ let next = 1;
120
+ let ended: Error | undefined;
121
+ const end = (error: Error): void => {
122
+ ended ??= error;
123
+ for (const pending of waiting.values()) pending.reject(ended);
124
+ waiting.clear();
125
+ };
126
+ child.stdout.setEncoding('utf8');
127
+ child.stderr.setEncoding('utf8');
128
+ child.stderr.on('data', (chunk: string) => (said = (said + chunk).slice(-2000)));
129
+ child.stdout.on('data', (chunk: string) => {
130
+ buffer += chunk;
131
+ for (let at = buffer.indexOf('\n'); at >= 0; at = buffer.indexOf('\n')) {
132
+ const reply = JSON.parse(buffer.slice(0, at)) as { id?: number; result?: unknown; error?: { message: string } };
133
+ buffer = buffer.slice(at + 1);
134
+ const pending = reply.id === undefined ? undefined : waiting.get(reply.id);
135
+ if (!pending) continue;
136
+ waiting.delete(reply.id!);
137
+ if (reply.error) pending.reject(new Error(reply.error.message));
138
+ else pending.resolve(reply.result);
139
+ }
140
+ });
141
+ child.on('error', (error: NodeJS.ErrnoException) =>
142
+ end(
143
+ new Error(
144
+ error.code === 'ENOENT'
145
+ ? `${cli} was not found: put figcraft-cli on the PATH, or name it with FIGCRAFT_CLI.`
146
+ : `${cli} did not start: ${error.message}`,
147
+ ),
148
+ ),
149
+ );
150
+ child.on('exit', (code) => end(new Error(`${cli} mcp ended (exit ${code})${said.trim() ? `: ${said.trim().split('\n').pop()}` : ''}`)));
151
+ child.stdin.on('error', () => {});
152
+
153
+ const send = (message: Record<string, unknown>): void => void child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', ...message })}\n`);
154
+ const rpc = (method: string, params: unknown): Promise<unknown> =>
155
+ ended
156
+ ? Promise.reject(ended)
157
+ : new Promise((resolve, reject) => {
158
+ const id = next++;
159
+ waiting.set(id, { resolve, reject });
160
+ send({ id, method, params });
161
+ });
162
+ /** A tool's answer, its text read as JSON; a tool error is a refusal, as the socket's `ok: false` is. */
163
+ const tool = async (name: string, args: Record<string, unknown>): Promise<unknown> => {
164
+ const { content, isError } = (await rpc('tools/call', { name, arguments: args })) as {
165
+ content: Array<{ text?: string }>;
166
+ isError?: boolean;
167
+ };
168
+ const text = content.map((part) => part.text ?? '').join('');
169
+ if (isError) throw new Error(text || `FigCraft refused ${name}`);
170
+ return JSON.parse(text);
171
+ };
172
+ const close = (): void => void child.stdin.end();
173
+
174
+ return (async () => {
175
+ try {
176
+ const { serverInfo } = (await rpc('initialize', {
177
+ protocolVersion: '2025-06-18',
178
+ capabilities: {},
179
+ clientInfo: { name: 'toghen-figma', version: '1' },
180
+ })) as { serverInfo?: { version?: string } };
181
+ const version = serverInfo?.version ?? 'of no version';
182
+ if (!/^\d+\.\d+\.\d+/.test(version) || before(version, HEADLESS_SINCE)) {
183
+ throw new Error(
184
+ `${cli} is ${version}: a headless run needs figcraft-cli ${HEADLESS_SINCE} or later, whose node.get reports the layer data that holds Toghen's stamps.`,
185
+ );
186
+ }
187
+ send({ method: 'notifications/initialized' });
188
+ if (existsSync(file)) await tool('open_file', { path: file });
189
+ const control: Control = {
190
+ version,
191
+ call: (method, params) =>
192
+ method === 'engine.execute'
193
+ ? tool('run_command', { command: params.command, params: params.params ?? {} })
194
+ : Promise.reject(new Error(`A headless FigCraft has no ${method}.`)),
195
+ open: async (path) => void (await tool('open_file', { path })),
196
+ save: async () => ((await tool('save_file', { path: file })) as { path?: string }).path ?? file,
197
+ close,
198
+ };
199
+ return control;
200
+ } catch (error) {
201
+ close();
202
+ throw error;
203
+ }
204
+ })();
205
+ }
206
+
207
+ /** A tab as `doc.list` lists it. */
208
+ interface Tab {
209
+ index: number;
210
+ name: string;
211
+ path: string | null;
212
+ active: boolean;
213
+ }
214
+
215
+ /** The file the run writes into: the window it is in, and the tab it is, as it was when the run began. */
216
+ export interface Pin {
217
+ window?: number;
218
+ tab: Tab;
219
+ /** Set once the file left its tab: every later request is refused before it is sent. */
220
+ lost?: string;
221
+ }
222
+
223
+ const sameFile = (a: Tab, b: Tab | undefined): boolean => b !== undefined && a.name === b.name && a.path === b.path;
224
+
225
+ /** FigCraft's front window, when it reports windows: a window id keeps the requests in one window when it has several. */
226
+ async function frontWindow(control: Control): Promise<number | undefined> {
227
+ // With one window (a screen reader keeps it to one), or headless, there is none to give.
228
+ const windows = (await control.call('window.list', {}).catch(() => [])) as Array<{ id: number; focused: boolean }>;
229
+ return windows.find((candidate) => candidate.focused)?.id;
230
+ }
231
+
232
+ const tabsIn = async (control: Control, window: number | undefined): Promise<Tab[]> =>
233
+ (await control.call('engine.execute', { command: 'doc.list', params: {}, ...(window !== undefined ? { window } : {}) })) as Tab[];
234
+
235
+ /** The file FigCraft shows in its front window. */
236
+ export async function pinShownFile(control: Control): Promise<Pin> {
237
+ const window = await frontWindow(control);
238
+ const tab = (await tabsIn(control, window)).find((candidate) => candidate.active);
239
+ if (!tab) throw new Error('FigCraft shows no file: open the file to sync into, then run again.');
240
+ return { ...(window !== undefined ? { window } : {}), tab };
241
+ }
242
+
243
+ /**
244
+ * The file at `path`, pinned: its tab where it is open, else a tab it is opened in. A file that does not exist yet is a
245
+ * new document named after it, with no path until it is saved there (`made`), which is the caller's to do.
246
+ */
247
+ export async function pinPath(control: Control, path: string): Promise<Pin & { made?: true }> {
248
+ const window = await frontWindow(control);
249
+ const at = { ...(window !== undefined ? { window } : {}) };
250
+ const open = (await tabsIn(control, window)).find((tab) => tab.path === path);
251
+ if (open) return { ...at, tab: open };
252
+ // FigCraft opens it, or says it is not there (`No such file or directory`); the file system it reads is its own.
253
+ const missing = await control.open(path, window).then(
254
+ () => false,
255
+ (error: Error) => {
256
+ if (/No such file|os error 2/i.test(error.message)) return true;
257
+ throw error;
258
+ },
259
+ );
260
+ if (!missing) {
261
+ const tab = (await tabsIn(control, window)).find((candidate) => candidate.path === path);
262
+ if (!tab) throw new Error(`FigCraft did not open ${path}.`);
263
+ return { ...at, tab };
264
+ }
265
+ const { index } = (await control.call('engine.execute', {
266
+ command: 'file.new',
267
+ params: { name: basename(path).replace(/\.figcraft$/i, '') },
268
+ ...at,
269
+ })) as { index: number };
270
+ const tab = (await tabsIn(control, window))[index];
271
+ if (!tab) throw new Error(`FigCraft did not make a file for ${path}.`);
272
+ return { ...at, tab, made: true };
273
+ }
274
+
275
+ /**
276
+ * Runs `steps` in the pinned file as one batch, one undo step named `label`, and answers their results. The batch
277
+ * shows the pinned file first, and lists the tabs last: a file that is no longer at its tab stops the run.
278
+ */
279
+ export async function inFile(control: Control, pin: Pin, label: string, steps: Step[]): Promise<unknown[]> {
280
+ if (pin.lost) throw new Error(pin.lost);
281
+ const [, results, tabs] = (await control.call('engine.execute', {
282
+ command: 'command.batch',
283
+ params: {
284
+ label,
285
+ commands: [
286
+ { command: 'doc.switch', params: { index: pin.tab.index } },
287
+ { command: 'command.batch', params: { commands: steps } },
288
+ { command: 'doc.list', params: {} },
289
+ ],
290
+ },
291
+ ...(pin.window !== undefined ? { window: pin.window } : {}),
292
+ })) as [unknown, unknown[], Tab[]];
293
+ if (!sameFile(pin.tab, tabs[pin.tab.index])) {
294
+ pin.lost = `FigCraft no longer has "${pin.tab.name}" at the tab it was in (a tab before it closed?): nothing more is written, and the step before this may have gone to "${tabs[pin.tab.index]?.name ?? 'no file'}".`;
295
+ throw new Error(pin.lost);
296
+ }
297
+ return results;
298
+ }
299
+
300
+ /**
301
+ * Saves the pinned file at `path`, in a batch of its own that shows it first. A file made by the run gets its path
302
+ * here, and its pin follows it.
303
+ */
304
+ export async function savePinned(control: Control, pin: Pin, path: string): Promise<void> {
305
+ if (pin.lost) throw new Error(pin.lost);
306
+ const [, , tabs] = (await control.call('engine.execute', {
307
+ command: 'command.batch',
308
+ params: {
309
+ commands: [
310
+ { command: 'doc.switch', params: { index: pin.tab.index } },
311
+ { command: 'file.save', params: { path } },
312
+ { command: 'doc.list', params: {} },
313
+ ],
314
+ },
315
+ ...(pin.window !== undefined ? { window: pin.window } : {}),
316
+ })) as [unknown, unknown, Tab[]];
317
+ const now = tabs[pin.tab.index];
318
+ if (now?.path !== path) {
319
+ pin.lost = `FigCraft no longer has "${pin.tab.name}" at the tab it was in: it was not saved at ${path}.`;
320
+ throw new Error(pin.lost);
321
+ }
322
+ pin.tab = now;
323
+ }
324
+
325
+ /** Closes the pinned file's tab, unsaved changes and all: only for a file the run made and did not save. */
326
+ export async function closeTab(control: Control, pin: Pin): Promise<void> {
327
+ await control.call('engine.execute', {
328
+ command: 'file.close',
329
+ params: { index: pin.tab.index, force: true },
330
+ ...(pin.window !== undefined ? { window: pin.window } : {}),
331
+ });
332
+ }
333
+
334
+ /** A FigCraft key as the parameters of a `library.*` command: the library's id, and the asset's id under `kind`. */
335
+ function libraryAsset(
336
+ key: string,
337
+ kind: 'component' | 'variable' | 'style',
338
+ ): { library: string; asset: string; params: Record<string, unknown> } {
339
+ const split = splitKey(key);
340
+ const what = kind === 'component' ? 'Component' : kind === 'variable' ? 'Variable' : 'Style';
341
+ if (!split)
342
+ throw new Error(
343
+ `${what} with key "${key}" not found: a FigCraft key is <library id>:<asset id>, and a Figma key names nothing in FigCraft`,
344
+ );
345
+ // A component's asset id is its node id in the library file, a number.
346
+ const asset = kind === 'component' && /^\d+$/.test(split.asset) ? Number(split.asset) : split.asset;
347
+ return { ...split, params: { library: split.library, [kind]: asset } };
348
+ }
349
+
350
+ type Handler = (payload: unknown) => Promise<void> | void;
351
+
352
+ /** The commands that read and write layers: they run against the file's layers as they are when each is sent. */
353
+ const COMPONENT_COMMANDS = new Set(['CHECK_COMPONENTS', 'RUN_COMPONENT', 'FINISH_COMPONENTS']);
354
+
355
+ /** What a run says outside its responses: Figma's toasts, which a plugin shows and the command line prints. */
356
+ interface Say {
357
+ stdout(text: string): void;
358
+ stderr(text: string): void;
359
+ }
360
+
361
+ /** A page as `page.list` lists it. */
362
+ interface Page {
363
+ id: number;
364
+ name: string;
365
+ current: boolean;
366
+ }
367
+
368
+ /** The page that holds the record, found by its data rather than its name, and the record it holds. */
369
+ async function readStamps(control: Control, pin: Pin): Promise<{ page?: number; record: StampRecord; extras: Extras }> {
370
+ const [pages] = (await inFile(control, pin, 'Read', [{ command: 'page.list', params: {} }])) as [Page[]];
371
+ const held = (await inFile(
372
+ control,
373
+ pin,
374
+ 'Read',
375
+ pages.map((page) => ({ command: 'node.get', params: { id: page.id, depth: 0 } })),
376
+ )) as Array<{ pluginData?: Record<string, string> }>;
377
+ const index = held.findIndex((page) => page.pluginData?.[RECORD_KEY]);
378
+ if (index < 0) return { record: new Map(), extras: readExtras(undefined) };
379
+ const text = held[index].pluginData![RECORD_KEY];
380
+ return { page: pages[index].id, record: readRecord(text), extras: readExtras(text) };
381
+ }
382
+
383
+ /**
384
+ * Writes the record back, as one undo step. With no page holding it yet, a divider is made for it: FigCraft shows a
385
+ * divider as a line in the pages list and never opens it, so the page shown stays shown.
386
+ */
387
+ async function writeStamps(control: Control, pin: Pin, page: number | undefined, record: StampRecord, extras: Extras): Promise<void> {
388
+ const data = { pluginData: { [RECORD_KEY]: recordText(record, extras) } };
389
+ await inFile(
390
+ control,
391
+ pin,
392
+ 'Toghen stamps',
393
+ page !== undefined
394
+ ? [{ command: 'node.set', params: { ids: [page], props: data } }]
395
+ : [
396
+ { command: 'page.add', params: { name: RECORD_PAGE } },
397
+ { command: 'node.set', params: { ids: [{ $step: 0 }], props: data } },
398
+ ],
399
+ );
400
+ }
401
+
402
+ /**
403
+ * Every page of the pinned file with its layers, as `node.get` answers them. `node.get` lists a layer's children one
404
+ * level down, whatever depth it is asked for (measured 23 October 2026), so the layers are read a level at a time, each
405
+ * level in one request.
406
+ */
407
+ async function readPages(control: Control, pin: Pin): Promise<{ pages: FcNode[]; current?: number }> {
408
+ const [list] = (await inFile(control, pin, 'Read', [{ command: 'page.list', params: {} }])) as [Page[]];
409
+ const pages = (await inFile(
410
+ control,
411
+ pin,
412
+ 'Read',
413
+ list.map((page) => ({ command: 'node.get', params: { id: page.id } })),
414
+ )) as FcNode[];
415
+ await readDown(control, pin, pages);
416
+ return { pages, current: list.find((page) => page.current)?.id };
417
+ }
418
+
419
+ /** The layers under `roots`, read a level at a time into each node's `children`. */
420
+ async function readDown(control: Control, pin: Pin, roots: FcNode[]): Promise<void> {
421
+ for (let level = roots; level.length > 0; ) {
422
+ const held = level.flatMap((node) => (node.children ?? []).filter((child) => (child as { childCount?: number }).childCount));
423
+ if (held.length === 0) break;
424
+ const read = (await inFile(
425
+ control,
426
+ pin,
427
+ 'Read',
428
+ held.map((node) => ({ command: 'node.get', params: { id: node.id } })),
429
+ )) as FcNode[];
430
+ held.forEach((node, at) => {
431
+ node.children = read[at].children;
432
+ });
433
+ level = held;
434
+ }
435
+ }
436
+
437
+ /**
438
+ * A library's copies in the file, by id, with what is in them. A copy is on no page, so `node.get` reaches it only by
439
+ * its id; one that is gone (FigCraft drops a copy nothing uses) is left out.
440
+ */
441
+ async function readCopies(control: Control, pin: Pin, ids: readonly string[]): Promise<FcNode[]> {
442
+ const copies: FcNode[] = [];
443
+ for (const id of ids) {
444
+ const [node] = (await inFile(control, pin, 'Read', [{ command: 'node.get', params: { id: Number(id) } }]).catch(() => [undefined])) as [
445
+ FcNode | undefined,
446
+ ];
447
+ if (node) copies.push(node);
448
+ }
449
+ await readDown(control, pin, copies);
450
+ return copies;
451
+ }
452
+
453
+ /**
454
+ * The `figma` the handlers meet: the reads the variable and style plans make, answered from FigCraft, and nothing
455
+ * else. FigCraft's variables and styles hold no data, so their stamps live in one record on a page (`figcraftRecord.ts`),
456
+ * read when the run begins and answered through each listed variable and style; `flush` writes it back.
457
+ */
458
+ async function figmaFor(control: Control, pin: Pin, say: Say, posted: Response[]) {
459
+ let listing: FcListing | undefined;
460
+ let view: FigmaView | undefined;
461
+ const stamps = await readStamps(control, pin);
462
+ const record = stamps.record;
463
+ let guarded = false;
464
+ let changed = false;
465
+ const fresh = async (): Promise<FigmaView> => {
466
+ if (!view) {
467
+ const [variables, styles] = (await inFile(control, pin, 'Read', [
468
+ { command: 'variable.list', params: {} },
469
+ { command: 'style.list', params: {} },
470
+ ])) as [Omit<FcListing, 'styles'>, FcStyle[]];
471
+ listing = { ...variables, styles };
472
+ view = figmaView(listing);
473
+ const listed = [...view.variables, ...view.styles];
474
+ // An id FigCraft gave again since the record was written names another object: its entry goes, once, at the first read.
475
+ if (!guarded) guard(record, listed);
476
+ guarded = true;
477
+ attach(listed, record, () => (changed = true));
478
+ }
479
+ return view;
480
+ };
481
+ let fonts: Set<string> | undefined;
482
+ const fontList = async (): Promise<Set<string>> => {
483
+ if (!fonts) {
484
+ const families = (await control.call('engine.execute', { command: 'text.fonts', params: {} })) as string[];
485
+ const styles = (await control.call('engine.execute', {
486
+ command: 'command.batch',
487
+ params: { commands: families.map((family) => ({ command: 'text.fonts', params: { family } })) },
488
+ })) as string[][];
489
+ fonts = new Set(families.flatMap((family, i) => (styles[i] ?? []).map((style) => `${family}\u0000${style}`)));
490
+ }
491
+ return fonts;
492
+ };
493
+ // The root's shared plugin data (the lock) persists in the record, as FigCraft's root holds none.
494
+ const shared = stamps.extras.root;
495
+ const stored = new Map<string, unknown>();
496
+ // The layers, as the component engine reads them (`figcraftScene.ts`), and what its reads write back.
497
+ const writes = noWrites();
498
+ let scene: Scene = sceneOf([], writes);
499
+ let current: string | undefined;
500
+ // What each library copy was imported from (`<library id>:<asset id>`), kept in the record: FigCraft gives no read for it.
501
+ const copies = stamps.extras.copies;
502
+ const loadScene = async (): Promise<Scene> => {
503
+ const read = await readPages(control, pin);
504
+ scene = sceneOf(read.pages, writes, { nodes: await readCopies(control, pin, [...copies.keys()]), keyOf: (id) => copies.get(id) });
505
+ current = read.current === undefined ? undefined : String(read.current);
506
+ return scene;
507
+ };
508
+ /** A library's component or set copied into the file (once: FigCraft's import is idempotent), and noted in the record. */
509
+ const importCopy = async (key: string): Promise<SceneNode> => {
510
+ const [imported] = (await inFile(control, pin, 'Import library component', [
511
+ { command: 'library.import', params: libraryAsset(key, 'component').params },
512
+ ])) as [{ component: number }];
513
+ const id = String(imported.component);
514
+ if (copies.get(id) !== key) {
515
+ copies.set(id, key);
516
+ changed = true;
517
+ }
518
+ return scene.byId(id) ?? scene.adopt((await readCopies(control, pin, [id]))[0], key);
519
+ };
520
+ const flushScene = async (label: string): Promise<void> => {
521
+ if (!hasWrites(writes)) return;
522
+ const steps = writeSteps(writes);
523
+ clearWrites(writes);
524
+ await inFile(control, pin, label, steps);
525
+ };
526
+ const currentPage = (): SceneNode | undefined => (current !== undefined ? scene.byId(current) : undefined) ?? scene.pages[0];
527
+ const stylesOf = async (type: string) => (await fresh()).styles.filter((style) => style.type === type);
528
+
529
+ const write = async (op: Operation): Promise<string | undefined> => {
530
+ await fresh();
531
+ if (
532
+ op.kind === 'DELETE_MODE' &&
533
+ view!.collections.find((collection) => collection.id === op.collectionId)?.defaultModeId === op.modeId
534
+ ) {
535
+ throw new Error(`The default mode "${op.modeName}" of collection "${op.collectionName}" is never deleted.`);
536
+ }
537
+ const held = op.kind === 'UPDATE_STYLE' ? listing!.styles.find((style) => style.id === op.styleId) : undefined;
538
+ const translation = translate(op, held);
539
+ if (translation.steps.length === 0) return translation.idOf([]);
540
+ // A deleted variable's id may be given to the next one made: its entry goes with it.
541
+ if (op.kind === 'DELETE_VARIABLE' && record.delete(op.variableId)) changed = true;
542
+ try {
543
+ return translation.idOf(await inFile(control, pin, labelForOperation(op), translation.steps));
544
+ } finally {
545
+ view = undefined;
546
+ }
547
+ };
548
+
549
+ const api = {
550
+ variables: {
551
+ getLocalVariableCollectionsAsync: async () => (await fresh()).collections,
552
+ getLocalVariablesAsync: async (type?: VariableResolvedDataType) =>
553
+ (await fresh()).variables.filter((variable) => !type || variable.resolvedType === type),
554
+ getVariableByIdAsync: async (id: string) => {
555
+ const held = await fresh();
556
+ return [...held.variables, ...(held.copies?.variables ?? [])].find((variable) => variable.id === id) ?? null;
557
+ },
558
+ getVariableCollectionByIdAsync: async (id: string) => {
559
+ const held = await fresh();
560
+ return [...held.collections, ...(held.copies?.collections ?? [])].find((collection) => collection.id === id) ?? null;
561
+ },
562
+ importVariableByKeyAsync: async (key: string) => {
563
+ const [imported] = (await inFile(control, pin, 'Import library variable', [
564
+ { command: 'library.importVariable', params: libraryAsset(key, 'variable').params },
565
+ ])) as [{ variable: string }];
566
+ view = undefined;
567
+ const variable = (await fresh()).copies?.variables.find((candidate) => candidate.id === imported.variable);
568
+ if (!variable) throw new Error(`Variable with key "${key}" not found`);
569
+ return variable;
570
+ },
571
+ },
572
+ getStyleByIdAsync: async (id: string) => {
573
+ const held = await fresh();
574
+ return [...held.styles, ...(held.copies?.styles ?? [])].find((style) => style.id === id) ?? null;
575
+ },
576
+ // FigCraft copies a library's style in only as it applies it: a style not yet copied is answered from the library's
577
+ // listing with the copy's id it will have, and the writer applies it with `library.applyStyle` (figcraftApply.ts).
578
+ importStyleByKeyAsync: async (key: string) => {
579
+ const held = (await fresh()).copies?.styles.find((style) => style.id === key);
580
+ if (held) return held;
581
+ const { library, asset } = libraryAsset(key, 'style');
582
+ const [list] = (await inFile(control, pin, 'Read', [{ command: 'library.list', params: { assets: true } }])) as [
583
+ { libraries: FcLibrary[] },
584
+ ];
585
+ const listed = list.libraries.find((candidate) => candidate.id === library)?.styles?.find((style) => style.id === asset);
586
+ if (!listed) throw new Error(`Style with key "${key}" not found`);
587
+ return { id: key, key, name: listed.name, type: listed.type.toUpperCase(), remote: true };
588
+ },
589
+ importComponentByKeyAsync: async (key: string) => {
590
+ const node = await importCopy(key);
591
+ if (node.type !== 'COMPONENT') throw new Error(`Component with key "${key}" not found`);
592
+ return node;
593
+ },
594
+ importComponentSetByKeyAsync: async (key: string) => {
595
+ const node = await importCopy(key);
596
+ if (node.type !== 'COMPONENT_SET') throw new Error(`Component set with key "${key}" not found`);
597
+ return node;
598
+ },
599
+ getNodeByIdAsync: async (id: string) => scene.byId(id) ?? null,
600
+ setCurrentPageAsync: async () => {},
601
+ loadAllPagesAsync: async () => {},
602
+ get currentPage() {
603
+ return currentPage();
604
+ },
605
+ getLocalTextStylesAsync: () => stylesOf('TEXT'),
606
+ getLocalEffectStylesAsync: () => stylesOf('EFFECT'),
607
+ getLocalPaintStylesAsync: () => stylesOf('PAINT'),
608
+ listAvailableFontsAsync: async () =>
609
+ [...(await fontList())].map((key) => {
610
+ const [family, style] = key.split('\u0000');
611
+ return { fontName: { family, style } };
612
+ }),
613
+ loadFontAsync: async (font: FontName) => {
614
+ if (!(await fontList()).has(`${font.family}\u0000${font.style}`))
615
+ throw new Error(`FigCraft does not list ${font.family} ${font.style}`);
616
+ },
617
+ root: {
618
+ get children() {
619
+ return scene.pages;
620
+ },
621
+ getSharedPluginData: (namespace: string, key: string) => shared.get(`${namespace}/${key}`) ?? '',
622
+ setSharedPluginData: (namespace: string, key: string, value: string) => {
623
+ const at = `${namespace}/${key}`;
624
+ if ((shared.get(at) ?? '') === value) return;
625
+ if (value === '') shared.delete(at);
626
+ else shared.set(at, value);
627
+ changed = true;
628
+ },
629
+ getSharedPluginDataKeys: (namespace: string) =>
630
+ [...shared.keys()].filter((key) => key.startsWith(`${namespace}/`)).map((key) => key.slice(namespace.length + 1)),
631
+ },
632
+ clientStorage: {
633
+ getAsync: async (key: string) => stored.get(key),
634
+ setAsync: async (key: string, value: unknown) => void stored.set(key, value),
635
+ deleteAsync: async (key: string) => void stored.delete(key),
636
+ },
637
+ ui: { postMessage: (message: Response) => void posted.push(message) },
638
+ notify: (message: string) => say.stderr(`${message}\n`),
639
+ };
640
+ // A read the plans do not make here is a FigCraft target that is incomplete, said as such rather than as a TypeError.
641
+ const figma = new Proxy(api, {
642
+ get: (target, property) => {
643
+ if (property in target) return target[property as keyof typeof target];
644
+ throw new Error(`The FigCraft target does not offer figma.${String(property)}.`);
645
+ },
646
+ });
647
+ /** Writes the record back when the run stamped anything: never in a dry run, which writes nothing. */
648
+ const flush = async (): Promise<void> => {
649
+ if (changed) {
650
+ await writeStamps(control, pin, stamps.page, record, stamps.extras);
651
+ // A first write made the page: the next flush writes onto it.
652
+ if (stamps.page === undefined) stamps.page = (await readStamps(control, pin)).page;
653
+ }
654
+ changed = false;
655
+ };
656
+ return { figma, write, flush, loadScene, flushScene };
657
+ }
658
+
659
+ /** A bridge to one pinned file: the plugin's handlers, run here, reading and writing that file. */
660
+ export type FigcraftBridge = Bridge & {
661
+ pin: Pin;
662
+ control: Control;
663
+ version?: string;
664
+ /** A headless session's target is saved by the run once it has completed. */
665
+ save?(): Promise<string>;
666
+ /** Writes the record (stamps, the lock) now rather than at close: before the file is saved by the run. */
667
+ flush(): Promise<void>;
668
+ };
669
+
670
+ /**
671
+ * A bridge to a file open in FigCraft, at a control port, or to a file run headless: the plugin's handlers, run here,
672
+ * reading and writing through the control socket or the session. It answers the commands the command line sends, as
673
+ * the plugin would. A headless bridge also saves its file (`save`), which the run asks for once it has completed.
674
+ */
675
+ export async function openFigcraft(at: number | { file: string }, say: Say): Promise<FigcraftBridge> {
676
+ const control = typeof at === 'number' ? await connect(at) : await headless(at.file);
677
+ try {
678
+ return await bridgeOn(control, await pinShownFile(control), say, true);
679
+ } catch (error) {
680
+ control.close();
681
+ throw error;
682
+ }
683
+ }
684
+
685
+ /**
686
+ * A bridge to the pinned file over a connection it may share with another (a library run holds two files). Each
687
+ * request makes its own `figma` and writers the global ones before the handler runs, so two bridges take turns.
688
+ * `owns` says whether closing the bridge closes the connection.
689
+ */
690
+ export async function bridgeOn(control: Control, pin: Pin, say: Say, owns: boolean): Promise<FigcraftBridge> {
691
+ const posted: Response[] = [];
692
+ const target = await figmaFor(control, pin, say, posted);
693
+ const { figma, write, flush, loadScene, flushScene } = target;
694
+ const writer = figcraftWriter({
695
+ batch: (label, steps) => inFile(control, pin, label, steps),
696
+ scene: loadScene,
697
+ flush: flushScene,
698
+ });
699
+ const global = globalThis as { figma?: unknown };
700
+ const activate = (): void => {
701
+ global.figma = figma;
702
+ writeOperationsWith(write);
703
+ writeComponentsWith(writer);
704
+ pinLibraryModes(true);
705
+ };
706
+ activate();
707
+
708
+ const save = control.save;
709
+ return {
710
+ port: 0,
711
+ pin,
712
+ control,
713
+ ...(control.version !== undefined ? { version: control.version } : {}),
714
+ // The stamps go in first, so the file saved holds them; the file is shown first, since a session saves the one it shows.
715
+ ...(owns && save
716
+ ? {
717
+ save: async () => {
718
+ await flush();
719
+ await inFile(control, pin, 'Read', [{ command: 'doc.list', params: {} }]);
720
+ return save();
721
+ },
722
+ }
723
+ : {}),
724
+ paired: Promise.resolve(),
725
+ flush,
726
+ async request(command: Command, answers: Response['type'][], onOther?: (response: Response) => void): Promise<Response> {
727
+ posted.length = 0;
728
+ activate();
729
+ const handler = (handlers as unknown as Record<string, Handler>)[command.type];
730
+ try {
731
+ if (!handler) throw new Error(`Unknown command: ${command.type}`);
732
+ // A component command reads the layers as they are now, and what its reads wrote back goes to FigCraft after it.
733
+ const layers = COMPONENT_COMMANDS.has(command.type);
734
+ if (layers) await loadScene();
735
+ await handler(command.payload);
736
+ if (layers) await flushScene(`Toghen: ${command.type === 'FINISH_COMPONENTS' ? 'restack' : 'stamps'}`);
737
+ } catch (error) {
738
+ posted.push({ type: 'ERROR', payload: { message: (error as Error).message, code: 'FIGMA_API_ERROR', commandType: command.type } });
739
+ }
740
+ for (const response of posted.splice(0)) {
741
+ if (answers.includes(response.type) || response.type === 'ERROR') return response;
742
+ onOther?.(response);
743
+ }
744
+ throw new Error(`${command.type}: nothing answered`);
745
+ },
746
+ async close(): Promise<void> {
747
+ // Written once, at the end, and when a run stops partway, so the stamps of what it did write are kept.
748
+ try {
749
+ await flush();
750
+ } catch (error) {
751
+ say.stderr(
752
+ `The stamps were not written into FigCraft: ${(error as Error).message}. The next run adopts what this one wrote by name.\n`,
753
+ );
754
+ }
755
+ if (global.figma === figma) {
756
+ writeOperationsWith(undefined);
757
+ writeComponentsWith(undefined);
758
+ pinLibraryModes(false);
759
+ delete global.figma;
760
+ }
761
+ if (owns) control.close();
762
+ },
763
+ };
764
+ }