@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,273 @@
1
+ /**
2
+ * Makes Figma agree with a component file: resolve, snapshot, diff, apply.
3
+ *
4
+ * `desired` is a component file already read, validated and expanded
5
+ * (`file.ts` does that, from the file's text). Everything the members name is
6
+ * addressed by name and resolved here, and a name that does not resolve stops the
7
+ * run before its first write: the plugin does not roll back, so the engine
8
+ * checks everything it can foresee first.
9
+ *
10
+ * Run one file at a time against a Figma file: two interleave.
11
+ */
12
+
13
+ import { diff, diffArtboard, type Receipts } from '../../shared/components/diff.js';
14
+ import type { Desired, Report, RunOptions, Step } from '../../shared/components/steps.js';
15
+ import { applyCreateArtboard, applyCreateSet, shownDefault, type Applied, type AppliedArtboard } from './apply.js';
16
+ import { noteTouched, pageOf } from './endOfRun.js';
17
+ import { ComponentRunError } from './errors.js';
18
+ import { expectedOf, type Wiring } from './receipt.js';
19
+ import { plannedValueLines } from './fieldWriter.js';
20
+ import { preferredOf, resolve, type Resolved } from './resolve.js';
21
+ import { copiesFinder, keepsNoStamp, outermost } from './stamp.js';
22
+ import { applyArtboardUpdate, applyUpdate, keepsChildren, type Updated, type UpdatedArtboard } from './update.js';
23
+ import { takeArtboardSnapshot, takeSnapshot } from './snapshot.js';
24
+
25
+ const refusal = (step: Step): step is Extract<Step, { kind: 'REFUSE_FOREIGN' }> => step.kind === 'REFUSE_FOREIGN';
26
+
27
+ /** What writes a run's steps into another file than Figma's: the command line's FigCraft target (`cli/figcraftApply.ts`). */
28
+ export interface ComponentWriter {
29
+ createSet(step: Extract<Step, { kind: 'CREATE_SET' }>, resolved: Resolved): Promise<Applied>;
30
+ update(setId: string, steps: Step[], desired: Desired, resolved: Resolved): Promise<Updated>;
31
+ createArtboard(step: Extract<Step, { kind: 'CREATE_ARTBOARD' }>, resolved: Resolved): Promise<AppliedArtboard>;
32
+ updateArtboard(frameId: string, steps: Step[], desired: Desired, resolved: Resolved): Promise<UpdatedArtboard>;
33
+ /** What the plan needs that this writer does not build, one line each: a run, a dry run included, stops on any. */
34
+ refuses(desired: Desired, steps: Step[], resolved: Resolved): string[];
35
+ }
36
+
37
+ let writer: ComponentWriter | undefined;
38
+
39
+ /** Sends every run's steps to `next` instead of Figma's API; `undefined` puts Figma's back. */
40
+ export function writeComponentsWith(next: ComponentWriter | undefined): void {
41
+ writer = next;
42
+ }
43
+
44
+ /** Stops the run, before it writes or reports a plan, when the writer cannot build what the plan needs. */
45
+ function refuseUnbuilt(desired: Desired, steps: Step[], resolved: Resolved): void {
46
+ const lines = writer?.refuses(desired, steps, resolved) ?? [];
47
+ if (lines.length > 0) throw new ComponentRunError(`will not build "${desired.component.name}": nothing was written`, lines);
48
+ }
49
+
50
+ /**
51
+ * What Figma's own writer does not build: a preferred value by name. Figma takes preferred values by published key, and
52
+ * whether it takes a resolved component's key, a local one's included, waits for a measurement in the desktop app.
53
+ */
54
+ function refusedInFigma(desired: Desired): string[] {
55
+ const lines: string[] = [];
56
+ for (const property of desired.component.properties) {
57
+ const named = preferredOf(property).find((value) => 'name' in value) as { loc?: { line: number } } | undefined;
58
+ if (named)
59
+ lines.push(
60
+ `"${desired.component.name}": the preferred values of "${property.name}" by name are not written in Figma until measured in the desktop app; name them by key${named.loc ? ` (line ${named.loc.line})` : ''}`,
61
+ );
62
+ }
63
+ return lines;
64
+ }
65
+
66
+ /** What a plan says about what it leaves alone, what it overwrites and what it breaks, for the report. */
67
+ function describe(steps: Step[]): Pick<Report, 'notes' | 'breaking' | 'warnings'> {
68
+ const notes: string[] = [];
69
+ const breaking: string[] = [];
70
+ const warnings: string[] = [];
71
+ for (const step of steps) {
72
+ if (step.kind === 'LEAVE') notes.push(step.message);
73
+ if (step.kind === 'OVERWRITE') warnings.push(step.message);
74
+ if ('breaks' in step && step.breaks) breaking.push(step.breaks);
75
+ }
76
+ return { notes, breaking, warnings };
77
+ }
78
+
79
+ /**
80
+ * Each step that deletes a node names the person's copies of it, and of what is in it, whose stamps it strips once it
81
+ * runs; its own row says so (decisions, *A copy whose original Toghen removes*). A copy is named by the first step that
82
+ * removes what it copies, so a layer deleted inside another is said once.
83
+ */
84
+ async function withStrips(steps: Step[]): Promise<void> {
85
+ let copiesOf: ReturnType<typeof copiesFinder> | undefined;
86
+ const named = new Set<string>();
87
+ for (const step of steps) {
88
+ if (step.kind !== 'DELETE_MEMBER' && step.kind !== 'DELETE_LAYER' && step.kind !== 'REPLACE_LAYER') continue;
89
+ const node = (await figma.getNodeByIdAsync(step.kind === 'DELETE_MEMBER' ? step.memberId : step.nodeId)) as SceneNode | null;
90
+ if (!node) continue;
91
+ copiesOf ??= copiesFinder();
92
+ const copies = copiesOf(node, step.kind !== 'REPLACE_LAYER' || !keepsChildren(step.layer, node)).filter((copy) => !named.has(copy.id));
93
+ if (copies.length === 0) continue;
94
+ for (const copy of copies) named.add(copy.id);
95
+ step.strip = copies.map((copy) => copy.id);
96
+ const said = keepsNoStamp(outermost(copies));
97
+ if (step.breaks) step.breaks = `${step.breaks}; ${said}`;
98
+ else if (step.kind === 'DELETE_LAYER') step.message = `removes "${step.address}"; ${said}`;
99
+ }
100
+ }
101
+
102
+ /** What the diff compares each layer's receipt with: the receipt the file's value would leave. */
103
+ const receiptsOf = (resolved: Resolved, wiring: Wiring, options: RunOptions): Receipts => ({
104
+ expected: (layer) => expectedOf(layer, resolved, wiring),
105
+ component: (ref) => resolved.componentSource(ref),
106
+ preferred: (value) => {
107
+ const node = resolved.preferred(value);
108
+ return node ? { type: value.type, key: node.key } : value;
109
+ },
110
+ defaultComponent: (ref) => {
111
+ try {
112
+ return resolved.component(ref).id;
113
+ } catch (_missing) {
114
+ // A component the run cannot find is said by the write that needs it; here it only means the default is set again.
115
+ return undefined;
116
+ }
117
+ },
118
+ ...(options.overwriteDrift ? { overwriteDrift: true } : {}),
119
+ });
120
+
121
+ /** Tells the end of the run which page a set or an artboard is on, the run's page for one yet to be made. */
122
+ async function noteSet(existingId: string | undefined, page: PageNode): Promise<void> {
123
+ const node = existingId === undefined ? null : await figma.getNodeByIdAsync(existingId);
124
+ noteTouched(node ? pageOf(node) : page);
125
+ }
126
+
127
+ export async function run(desired: Desired, options: RunOptions = {}): Promise<Report> {
128
+ // A real run builds against what is in the file. A name only a plan said would exist would be a binding to nothing.
129
+ if (options.pending && !options.dryRun) {
130
+ throw new ComponentRunError(`will not build "${desired.component.name}": nothing was written`, [
131
+ 'pending names are for a dry run: a real run resolves against what the file holds, and the steps before it have made those names',
132
+ ]);
133
+ }
134
+ if (!writer) {
135
+ const refused = refusedInFigma(desired);
136
+ if (refused.length > 0) throw new ComponentRunError(`will not build "${desired.component.name}": nothing was written`, refused);
137
+ }
138
+ // Under dynamic-page access a page's contents are not there until it is loaded, and the snapshot reads every page.
139
+ // Page by page, not loadAllPagesAsync(): a measured choice, and it loads only what the snapshot reads.
140
+ // The searches then run page by page too (findInEveryPage): the plugin may not search the document itself.
141
+ for (const page of figma.root.children) await page.loadAsync();
142
+
143
+ const resolved = await resolve(desired, options);
144
+ if (desired.artboard) return runArtboard(desired, resolved, options);
145
+ const snapshot = await takeSnapshot(desired.component.name);
146
+ const wiring: Wiring = {
147
+ keys: new Map((snapshot.set?.definitions ?? []).map((definition) => [definition.name, definition.key])),
148
+ defaults: new Map(),
149
+ };
150
+ for (const property of desired.component.properties) {
151
+ if (property.type !== 'BOOLEAN' && property.type !== 'TEXT') continue;
152
+ try {
153
+ wiring.defaults.set(property.name, shownDefault(resolved, property));
154
+ } catch (_pending) {
155
+ // A default's variable a dry run only expects: a field wired to it reads as moved, and is written.
156
+ }
157
+ }
158
+ const steps = diff(desired.members, desired.component, snapshot, receiptsOf(resolved, wiring, options));
159
+ await withStrips(steps);
160
+ refuseUnbuilt(desired, steps, resolved);
161
+ const report: Report = {
162
+ dryRun: !!options.dryRun,
163
+ steps,
164
+ created: [],
165
+ updated: [],
166
+ deleted: [],
167
+ pending: resolved.pending,
168
+ ...describe(steps),
169
+ };
170
+ report.notes.push(...resolved.notes);
171
+
172
+ if (options.dryRun) {
173
+ // What the run would write as a value is said before it runs (R10).
174
+ report.warnings.push(...plannedValueLines(desired.component.name, resolved, steps));
175
+ await noteSet(snapshot.set?.id, resolved.page);
176
+ return report;
177
+ }
178
+
179
+ const refused = steps.filter(refusal);
180
+ if (refused.length > 0) {
181
+ throw new ComponentRunError(
182
+ `will not build "${desired.component.name}": nothing was written`,
183
+ refused.map((step) => step.message),
184
+ );
185
+ }
186
+
187
+ // Breaking steps are advisory in a dry run and a gate in a real one: only someone who read them and said so takes them.
188
+ if (report.breaking.length > 0 && !options.allowBreaking) {
189
+ throw new ComponentRunError(
190
+ `will not change "${desired.component.name}": ${report.breaking.length} step${report.breaking.length === 1 ? '' : 's'} would break instances, and nothing was written; pass allowBreaking to take ${report.breaking.length === 1 ? 'it' : 'them'}`,
191
+ report.breaking,
192
+ );
193
+ }
194
+
195
+ const create = steps.find((step): step is Extract<Step, { kind: 'CREATE_SET' }> => step.kind === 'CREATE_SET');
196
+ if (create) {
197
+ const applied = await (writer ? writer.createSet(create, resolved) : applyCreateSet(create, resolved));
198
+ report.setId = applied.setId;
199
+ noteTouched(resolved.page, applied.setId);
200
+ report.created.push(...applied.created);
201
+ report.warnings.push(...applied.warnings);
202
+ } else if (snapshot.set) {
203
+ const applied = await (writer
204
+ ? writer.update(snapshot.set.id, steps, desired, resolved)
205
+ : applyUpdate(snapshot.set.id, steps, desired, resolved));
206
+ report.setId = snapshot.set.id;
207
+ await noteSet(snapshot.set.id, resolved.page);
208
+ report.created.push(...applied.created);
209
+ report.updated.push(...applied.updated);
210
+ report.deleted.push(...applied.deleted);
211
+ report.warnings.push(...applied.warnings);
212
+ }
213
+ return report;
214
+ }
215
+
216
+ /**
217
+ * An artboard: snapshot its frame by stamp, plan the difference, and apply it.
218
+ *
219
+ * The gate is the set's: a layer the file stopped producing is removed, which is a breaking step, so a real run
220
+ * that has one stops before its first write unless it was told to take it. A layer without our stamp is never touched.
221
+ */
222
+ async function runArtboard(desired: Desired, resolved: Resolved, options: RunOptions): Promise<Report> {
223
+ const [member] = desired.members;
224
+ const snapshot = await takeArtboardSnapshot(member.name);
225
+ const steps = diffArtboard(member, snapshot, receiptsOf(resolved, { keys: new Map(), defaults: new Map() }, options));
226
+ await withStrips(steps);
227
+ refuseUnbuilt(desired, steps, resolved);
228
+ const report: Report = {
229
+ artboard: true,
230
+ dryRun: !!options.dryRun,
231
+ steps,
232
+ created: [],
233
+ updated: [],
234
+ deleted: [],
235
+ pending: resolved.pending,
236
+ ...describe(steps),
237
+ };
238
+ report.notes.push(...resolved.notes);
239
+ if (options.dryRun) {
240
+ report.warnings.push(...plannedValueLines(member.name, resolved, steps));
241
+ await noteSet(snapshot.artboard?.id, resolved.page);
242
+ return report;
243
+ }
244
+
245
+ if (report.breaking.length > 0 && !options.allowBreaking) {
246
+ throw new ComponentRunError(
247
+ `will not change "${member.name}": ${report.breaking.length} step${report.breaking.length === 1 ? '' : 's'} would remove layers, and nothing was written; pass allowBreaking to take ${report.breaking.length === 1 ? 'it' : 'them'}`,
248
+ report.breaking,
249
+ );
250
+ }
251
+
252
+ const create = steps.find((step): step is Extract<Step, { kind: 'CREATE_ARTBOARD' }> => step.kind === 'CREATE_ARTBOARD');
253
+ if (create) {
254
+ const applied = await (writer ? writer.createArtboard(create, resolved) : applyCreateArtboard(create, resolved));
255
+ report.setId = applied.frameId;
256
+ noteTouched(resolved.page, applied.frameId);
257
+ report.created.push(...applied.created);
258
+ report.warnings.push(...applied.warnings);
259
+ } else if (snapshot.artboard) {
260
+ report.setId = snapshot.artboard.id;
261
+ // A run with nothing to change does not even switch pages.
262
+ if (!steps.some((step) => step.kind !== 'LEAVE' && step.kind !== 'OVERWRITE')) return report;
263
+ const applied = await (writer
264
+ ? writer.updateArtboard(snapshot.artboard.id, steps, desired, resolved)
265
+ : applyArtboardUpdate(snapshot.artboard.id, steps, desired, resolved));
266
+ await noteSet(snapshot.artboard.id, resolved.page);
267
+ report.created.push(...applied.created);
268
+ report.updated.push(...applied.updated);
269
+ report.deleted.push(...applied.deleted);
270
+ report.warnings.push(...applied.warnings);
271
+ }
272
+ return report;
273
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * What is in the file that is ours, as plain data.
3
+ *
4
+ * Finds a component's set, its members and their layers by stamp, wherever a
5
+ * designer moved them, and reports what it found that is *not* ours: a set of the
6
+ * same name that Toghen did not make, nodes inside a set that carry no stamp, and
7
+ * the copies a person made of Toghen's nodes, which carry their stamps.
8
+ * The diff is taken from this, and it never sees a Figma node.
9
+ *
10
+ * Must run after every page is loaded (`page.loadAsync()`): under dynamic-page
11
+ * access a page's contents are not there until it is loaded.
12
+ */
13
+
14
+ import { findInEveryPage } from '../utils/pageRoots.js';
15
+ import { parseCombination, slotKey } from '../../shared/components/diff.js';
16
+ import type {
17
+ ArtboardSnapshot,
18
+ LayerSnapshot,
19
+ MemberSnapshot,
20
+ PropertySnapshot,
21
+ Snapshot,
22
+ SetSnapshot,
23
+ } from '../../shared/components/diff.js';
24
+ import type { LayerKind, SlotSettings as SlotModelSettings } from '../../shared/components/model.js';
25
+ import { slotNodesOf } from './apply.js';
26
+ import { ComponentRunError } from './errors.js';
27
+ import { receiptOf, sortNodes, stampedAs, stampOn, type Sorted, type Stamp } from './stamp.js';
28
+ import { reportedOf } from './receipt.js';
29
+ import { loadLock } from '../utils/lockStore.js';
30
+
31
+ const LAYER_TYPES: Record<string, LayerKind> = { FRAME: 'FRAME', SLOT: 'SLOT', TEXT: 'TEXT', INSTANCE: 'INSTANCE', LINE: 'LINE' };
32
+
33
+ /**
34
+ * A stamped layer's receipt, and the hash of what Figma reports now for each field with a hash in it. An instance's
35
+ * component is always read, and said as `main`: the diff tells a swap from a receipt to take again by it (`SWAP_COMPONENT`).
36
+ */
37
+ async function receiptAndReported(
38
+ node: SceneNode,
39
+ receipt: Record<string, string>,
40
+ libraryOf: () => Map<string, string>,
41
+ ): Promise<Pick<LayerSnapshot, 'receipt' | 'reported' | 'main'>> {
42
+ const fields = Object.keys(receipt).filter((field) => receipt[field] !== '');
43
+ if (node.type !== 'INSTANCE') return { receipt, reported: receiptOf(await reportedOf(node, fields)) };
44
+ const reported = receiptOf(await reportedOf(node, fields.includes('component') ? fields : [...fields, 'component']));
45
+ const component = await node.getMainComponentAsync();
46
+ const shown = component?.parent?.type === 'COMPONENT_SET' ? component.parent : component;
47
+ if (!shown) return { receipt, reported };
48
+ return {
49
+ receipt,
50
+ reported,
51
+ main: { name: shown.name, key: shown.key, ...(shown.remote ? { library: libraryOf().get(shown.key) ?? '' } : {}) },
52
+ };
53
+ }
54
+
55
+ /** The library the lock holds each component key under, read once a snapshot meets a library's component. */
56
+ function librariesByKey(): () => Map<string, string> {
57
+ let byKey: Map<string, string> | undefined;
58
+ return () => {
59
+ if (byKey) return byKey;
60
+ byKey = new Map();
61
+ for (const [library, entry] of Object.entries(loadLock().libraries))
62
+ for (const { key } of Object.values(entry.components)) byKey.set(key, library);
63
+ return byKey;
64
+ };
65
+ }
66
+
67
+ /** A node of a member or an artboard's frame, as the walk found it. */
68
+ export interface TreeNode {
69
+ node: SceneNode;
70
+ index: number;
71
+ /** The key of the layer it sits in: an address, or for a layer in an instance's slot, `slotKey` of the instance and the slot. */
72
+ parent?: string;
73
+ /** Set on a layer Toghen stamped, of a kind it makes. */
74
+ stamp?: Stamp;
75
+ address?: string;
76
+ kind?: LayerKind;
77
+ /** Toghen's layer, moved: its stamp names another id (`sortOut`). */
78
+ moved?: true;
79
+ /** A person's copy of a layer Toghen made: the original. */
80
+ copyOf?: SceneNode;
81
+ }
82
+
83
+ /**
84
+ * The nodes of a member, or an artboard's frame, the root first: each stamped layer by the address in its stamp, sorted
85
+ * into Toghen's and a person's copies (`sortNodes`), and the nodes that carry no stamp. The walk goes into a stamped
86
+ * layer only: an unstamped node's contents are its own business, and an instance's children belong to its component;
87
+ * in an artboard the layers in an instance's slots are walked, under the slot's key. What is inside a copy is left out
88
+ * with it. The snapshot and the update both read a tree through this, so they agree on which node is which layer.
89
+ */
90
+ export async function stampedTree(root: SceneNode & ChildrenMixin, slots: boolean): Promise<TreeNode[]> {
91
+ const found: Array<TreeNode & { up?: TreeNode }> = [];
92
+ const add = (
93
+ node: SceneNode,
94
+ index: number,
95
+ parent: string | undefined,
96
+ up: TreeNode | undefined,
97
+ kind: LayerKind | undefined,
98
+ ): TreeNode => {
99
+ const stamp = stampOn(node);
100
+ const address = stamp?.is.address;
101
+ const entry = {
102
+ node,
103
+ index,
104
+ ...(parent !== undefined ? { parent } : {}),
105
+ up,
106
+ ...(stamp && address !== undefined && kind ? { stamp, address, kind } : {}),
107
+ };
108
+ found.push(entry);
109
+ return entry;
110
+ };
111
+ const walk = (node: BaseNode & ChildrenMixin, parent: string | undefined, up: TreeNode): void => {
112
+ for (const [index, child] of (node.children as readonly SceneNode[]).entries()) {
113
+ const entry = add(child, index, parent, up, LAYER_TYPES[child.type]);
114
+ if (entry.address === undefined) continue;
115
+ if (child.type === 'INSTANCE') {
116
+ if (slots) for (const slot of slotNodesOf(child)) walk(slot, slotKey(entry.address, slot.name), entry);
117
+ } else if ('children' in child) {
118
+ walk(child, entry.address, entry);
119
+ }
120
+ }
121
+ };
122
+ const top = add(root, 0, undefined, undefined, 'FRAME');
123
+ walk(root, top.address, top);
124
+
125
+ const sorted = await sortNodes(found.filter((entry) => entry.address !== undefined).map((entry) => entry.node));
126
+ const copyOf = new Map(sorted.copies.map(({ copy, of }) => [copy, of]));
127
+ const underCopy = (entry: TreeNode & { up?: TreeNode }): boolean => {
128
+ for (let up = entry.up as (TreeNode & { up?: TreeNode }) | undefined; up; up = up.up) if (copyOf.has(up.node)) return true;
129
+ return false;
130
+ };
131
+ return found
132
+ .filter((entry) => !underCopy(entry))
133
+ .map(({ up: _up, ...entry }) => ({
134
+ ...entry,
135
+ ...(sorted.moved.has(entry.node) ? { moved: true as const } : {}),
136
+ ...(copyOf.has(entry.node) ? { copyOf: copyOf.get(entry.node) } : {}),
137
+ }));
138
+ }
139
+
140
+ /**
141
+ * A member, or an artboard's frame, as plain data: its layers by the address in their stamps, with their receipts, the
142
+ * copies a person made of them, and the nodes in it that carry no stamp. The root is the node itself: one node is the
143
+ * member (or the artboard) and a layer.
144
+ */
145
+ async function snapshotTree(root: ComponentNode | FrameNode, name: string, slots: boolean): Promise<MemberSnapshot> {
146
+ const layers: LayerSnapshot[] = [];
147
+ const unstamped: MemberSnapshot['unstamped'] = [];
148
+ const copies: NonNullable<MemberSnapshot['copies']> = [];
149
+ const libraryOf = librariesByKey();
150
+ for (const entry of await stampedTree(root, slots)) {
151
+ const { node } = entry;
152
+ const parent = entry.parent !== undefined ? { parent: entry.parent } : {};
153
+ if (!entry.stamp || entry.address === undefined || !entry.kind) {
154
+ // Not ours: reported, and left alone. What is inside it is its own business.
155
+ if (node !== root) unstamped.push({ id: node.id, name: node.name, ...parent });
156
+ } else if (entry.copyOf) {
157
+ copies.push({ id: node.id, name: node.name, address: entry.address });
158
+ } else {
159
+ layers.push({
160
+ id: node.id,
161
+ name: node.name,
162
+ address: entry.address,
163
+ kind: entry.kind,
164
+ index: entry.index,
165
+ ...(await receiptAndReported(node, entry.stamp.receipt, libraryOf)),
166
+ ...parent,
167
+ ...(entry.moved ? { moved: true } : {}),
168
+ });
169
+ }
170
+ }
171
+ return { id: root.id, name, layers, unstamped, ...(copies.length > 0 ? { copies } : {}) };
172
+ }
173
+
174
+ /** Figma reports an unset limit as null; the file leaves it out. */
175
+ function withoutNulls(settings: SlotSettings): SlotModelSettings {
176
+ const out: SlotModelSettings = {};
177
+ for (const [key, value] of Object.entries(settings))
178
+ if (value !== null && value !== undefined) (out as Record<string, unknown>)[key] = value;
179
+ return out;
180
+ }
181
+
182
+ /**
183
+ * What is wrong with a set Figma reports as broken, from its children's names and stamps, which stay readable when the
184
+ * set's own properties do not: members with other axes than most, a combination two members share, a name that does not
185
+ * read as `Axis=value, …`. Each is said to be ours or made by hand.
186
+ */
187
+ function describeBroken(set: ComponentSetNode, componentName: string): string[] {
188
+ const children = set.children.filter((child): child is ComponentNode => child.type === 'COMPONENT');
189
+ const who = (child: ComponentNode): string => (stampOn(child)?.is.member !== undefined ? 'ours' : 'made by hand');
190
+ const lines = [`cannot build "${componentName}": Figma reports its set (${set.id}) as broken, and nothing was written`];
191
+
192
+ const axesOf = new Map<ComponentNode, string>();
193
+ const byName = new Map<string, ComponentNode[]>();
194
+ for (const child of children) {
195
+ const pairs = parseCombination(child.name);
196
+ if (!pairs) {
197
+ lines.push(`member "${child.name}" (${child.id}, ${who(child)}) does not read as Axis=value, …`);
198
+ continue;
199
+ }
200
+ axesOf.set(child, pairs.map(([axis]) => axis).join(', '));
201
+ byName.set(child.name, [...(byName.get(child.name) ?? []), child]);
202
+ }
203
+ // The axes most members have; on a tie, the first met.
204
+ const counts = new Map<string, number>();
205
+ for (const axes of axesOf.values()) counts.set(axes, (counts.get(axes) ?? 0) + 1);
206
+ const usual = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
207
+ for (const [child, axes] of axesOf) {
208
+ if (usual && axes !== usual[0])
209
+ lines.push(`member "${child.name}" (${child.id}, ${who(child)}) has the axes ${axes}; the other ${usual[1]} have ${usual[0]}`);
210
+ }
211
+ for (const [name, same] of byName) {
212
+ if (same.length > 1)
213
+ lines.push(
214
+ `"${name}" is the name of ${same.length === 2 ? 'two' : same.length} members (${same.map((child) => `${child.id}, ${who(child)}`).join('; ')})`,
215
+ );
216
+ }
217
+ lines.push('delete or rename the members named, then run again');
218
+ return lines;
219
+ }
220
+
221
+ /** Figma's own message for a set it calls broken, the one every read of its properties then throws. */
222
+ const isBroken = (error: unknown): boolean => error instanceof Error && /Component set .*has existing errors/.test(error.message);
223
+
224
+ /** The lines that name what is wrong with the component's stamped set, when Figma reports it broken. Reads it, and nothing else. */
225
+ export function brokenSetLines(set: ComponentSetNode, componentName: string): string[] | undefined {
226
+ try {
227
+ void set.componentPropertyDefinitions;
228
+ return undefined;
229
+ } catch (error) {
230
+ if (!isBroken(error)) throw error;
231
+ return describeBroken(set, componentName);
232
+ }
233
+ }
234
+
235
+ /** The members of a set, Toghen's and a person's copies (`sortNodes`): its components stamped as members. */
236
+ export const membersOf = (set: ComponentSetNode): Promise<Sorted<ComponentNode>> =>
237
+ sortNodes(set.children.filter((child): child is ComponentNode => child.type === 'COMPONENT' && stampOn(child)?.is.member !== undefined));
238
+
239
+ async function snapshotSet(set: ComponentSetNode, componentName: string): Promise<SetSnapshot> {
240
+ const broken = brokenSetLines(set, componentName);
241
+ if (broken) throw new ComponentRunError(broken[0], broken.slice(1));
242
+ const members: MemberSnapshot[] = [];
243
+ const unstamped: SetSnapshot['unstamped'] = [];
244
+ const copies: NonNullable<SetSnapshot['copies']> = [];
245
+ const sorted = await membersOf(set);
246
+ const copyOf = new Map<SceneNode, ComponentNode>(sorted.copies.map(({ copy, of }) => [copy, of]));
247
+ for (const child of set.children) {
248
+ const of = copyOf.get(child);
249
+ if (of) copies.push({ id: child.id, name: child.name, of: stampOn(of)?.is.member ?? of.name });
250
+ else if (sorted.ours.includes(child as ComponentNode))
251
+ members.push(await snapshotTree(child as ComponentNode, stampOn(child)?.is.member as string, false));
252
+ else unstamped.push({ id: child.id, name: child.name });
253
+ }
254
+ // The set's properties, ours and not. A `VARIANT` axis is derived from the members' names and is not one to edit.
255
+ const definitions: PropertySnapshot[] = Object.entries(set.componentPropertyDefinitions)
256
+ .filter(([, definition]) => definition.type !== 'VARIANT')
257
+ .map(([key, definition]) => ({
258
+ key,
259
+ name: key.split('#')[0],
260
+ type: definition.type as PropertySnapshot['type'],
261
+ defaultValue: definition.defaultValue,
262
+ ...(definition.description !== undefined ? { description: definition.description } : {}),
263
+ ...(definition.preferredValues ? { preferredValues: definition.preferredValues } : {}),
264
+ ...(definition.slotSettings ? { slotSettings: withoutNulls(definition.slotSettings) } : {}),
265
+ }));
266
+ return {
267
+ id: set.id,
268
+ definitions,
269
+ properties: Object.keys(stampOn(set)?.receipt ?? {}),
270
+ members,
271
+ unstamped,
272
+ ...(copies.length > 0 ? { copies } : {}),
273
+ };
274
+ }
275
+
276
+ /** The sets stamped as this component, Toghen's and a person's copies (`sortNodes`). */
277
+ const stampedSets = (componentName: string): Promise<Sorted<ComponentSetNode>> =>
278
+ sortNodes(
279
+ findInEveryPage({ types: ['COMPONENT_SET'], sharedPluginData: stampedAs('component') }).filter(
280
+ (set): set is ComponentSetNode => stampOn(set)?.is.component === componentName,
281
+ ),
282
+ );
283
+
284
+ /** The lines naming what is wrong with each of these components' stamped sets that Figma reports as broken, for the check that runs before a sync writes anything. A copy is not Toghen's to check. */
285
+ export async function brokenSetProblems(componentNames: string[]): Promise<string[]> {
286
+ // The same page-by-page loading a run does before it reads the file.
287
+ if (componentNames.length > 0) for (const page of figma.root.children) await page.loadAsync();
288
+ const lines: string[] = [];
289
+ for (const name of componentNames) for (const set of (await stampedSets(name)).ours) lines.push(...(brokenSetLines(set, name) ?? []));
290
+ return lines;
291
+ }
292
+
293
+ /**
294
+ * Finds the set Toghen built for `componentName`, the copies a person made of it, and any set that merely has its name.
295
+ *
296
+ * A copy carries the set's stamp, and its id says it is a copy (`sortNodes`). Two sets whose stamps are both their own
297
+ * (stamped before the stamp recorded an id) cannot be told apart by anything but a guess, so that stops the run rather
298
+ * than being resolved by picking one.
299
+ */
300
+ export async function takeSnapshot(componentName: string): Promise<Snapshot> {
301
+ const { ours, copies } = await stampedSets(componentName);
302
+
303
+ if (ours.length > 1) {
304
+ throw new ComponentRunError(`cannot build "${componentName}": more than one set is stamped as it`, [
305
+ `${ours.map((set) => set.id).join(', ')} all claim to be "${componentName}"; delete or unstamp all but one`,
306
+ ]);
307
+ }
308
+
309
+ const foreign = findInEveryPage({ types: ['COMPONENT_SET'] })
310
+ .filter((set) => set.name === componentName && stampOn(set)?.is.component === undefined)
311
+ .map((set) => ({ id: set.id, name: set.name }));
312
+
313
+ return {
314
+ ...(ours.length === 1 ? { set: await snapshotSet(ours[0], componentName) } : {}),
315
+ foreign,
316
+ ...(copies.length > 0 ? { copies: copies.map(({ copy }) => ({ id: copy.id, name: copy.name })) } : {}),
317
+ };
318
+ }
319
+
320
+ /**
321
+ * Finds the frame Toghen built for the artboard `name`, wherever a person moved it, and the copies a person made of it.
322
+ *
323
+ * A frame that merely has the artboard's name is not looked at: nothing is adopted by name. Two frames whose stamps
324
+ * are both their own cannot be told apart, so that stops the run.
325
+ */
326
+ export async function takeArtboardSnapshot(name: string): Promise<ArtboardSnapshot> {
327
+ const { ours, copies } = await sortNodes(
328
+ findInEveryPage({ types: ['FRAME'], sharedPluginData: stampedAs('artboard') }).filter(
329
+ (frame): frame is FrameNode => stampOn(frame)?.is.artboard === name,
330
+ ),
331
+ );
332
+ if (ours.length > 1) {
333
+ throw new ComponentRunError(`cannot build "${name}": more than one frame is stamped as it`, [
334
+ `${ours.map((frame) => frame.id).join(', ')} all claim to be the artboard "${name}"; delete or unstamp all but one`,
335
+ ]);
336
+ }
337
+ return {
338
+ ...(ours.length === 1 ? { artboard: await snapshotTree(ours[0], name, true) } : {}),
339
+ ...(copies.length > 0 ? { copies: copies.map(({ copy }) => ({ id: copy.id, name: copy.name })) } : {}),
340
+ };
341
+ }