@toghen/figma 0.0.0-stage → 0.1.1

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 +1067 -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 +24161 -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 +565 -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,312 @@
1
+ /**
2
+ * What is ours in a Figma file, and how an object says so.
3
+ *
4
+ * The Figma file is a target of the token file, and Toghen keeps what it wrote there on each object it wrote: a
5
+ * stamp, in shared plugin data in the `toghen` namespace, the one the plugin already uses on the document root.
6
+ * Shared rather than private, because the script an agent runs and the plugin are different plugins and both must
7
+ * read the stamps. A stamp says what the object is and, for each field Toghen owns, a hash of what Figma reported
8
+ * right after the write: a receipt, never the value. Kept on the object, it cannot be carried onto another one.
9
+ *
10
+ * Deleting a set and building it again changes its key and breaks every instance of it, so the durable behaviour
11
+ * is to reconcile in place, and reconciling needs to know which nodes Toghen made. A name cannot say it, since a
12
+ * designer renames layers.
13
+ *
14
+ * `getStamp` and `setStamp` are the ruled form, one key on every object (decisions, *Ruled: the stamp, one receipt
15
+ * per object, for every target*). A node may still carry the six keys components were stamped with before it:
16
+ * `stampOn` reads them as a stamp, and `updateStamp` writes the stamp and deletes them, one way.
17
+ *
18
+ * A stamp records the id of the object it was written on, since a person's copy carries the original's stamp; `sortOut`
19
+ * tells the two apart (decisions, *Ruled: a copy carries the stamp*). A step that deletes an object Toghen wrote strips
20
+ * the stamp from every copy of it (`strip`, `copiesFinder`), so the copy is never taken for the original moved
21
+ * (decisions, *A copy whose original Toghen removes*).
22
+ *
23
+ * `reference.md`, *The stamp* and *Component files*, owns the keys and their values.
24
+ */
25
+
26
+ import { hashOf } from '../../shared/components/diff.js';
27
+ import { getState } from '../runtimeState.js';
28
+ import { flatMap } from '../utils/lists.js';
29
+ import { findInEveryPage } from '../utils/pageRoots.js';
30
+
31
+ export { hashOf };
32
+
33
+ export const NAMESPACE = 'toghen';
34
+
35
+ export interface Position {
36
+ x: number;
37
+ y: number;
38
+ }
39
+
40
+ /** The one key a stamp is kept under, on a variable, a collection, a style or a node alike. */
41
+ export const STAMP_KEY = 'stamp';
42
+
43
+ /** What an object is. A node can be two things at once: a member's root is a member and a layer. */
44
+ export interface Identity {
45
+ /** A variable or a style: the token it holds, by the path the sync keys it by. */
46
+ token?: string;
47
+ /** A collection of one modifier: the modifier's name. */
48
+ modifier?: string;
49
+ /** The base collection, which holds every token no modifier defines. */
50
+ base?: true;
51
+ /** A component set: the component's name in the file. */
52
+ component?: string;
53
+ /** A member: its combination, `Axis=value, Axis=value`. */
54
+ member?: string;
55
+ /** A layer, the root included: its address in the file. */
56
+ address?: string;
57
+ /** An artboard's frame: the artboard's name in the file. */
58
+ artboard?: string;
59
+ }
60
+
61
+ /** What Toghen keeps on an object it wrote. */
62
+ export interface Stamp {
63
+ is: Identity;
64
+ /**
65
+ * The Figma id of the object the stamp was written on. A copy a person makes carries the stamp of the object it was
66
+ * made from, and this is what tells the two apart (`isOwn`). Absent on a stamp written before it was recorded.
67
+ */
68
+ id?: string;
69
+ /** For each field Toghen owns on the object, a hash of what Figma reported right after Toghen wrote it (`receiptOf`). */
70
+ receipt: Record<string, string>;
71
+ /** On a set or an artboard's frame: where Toghen last put it. A node whose position still equals it has not been moved by hand. */
72
+ placed?: Position;
73
+ }
74
+
75
+ const IDENTITY_TEXT = ['token', 'modifier', 'component', 'member', 'address', 'artboard'] as const;
76
+
77
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
78
+
79
+ const positionOf = (value: unknown): { placed: Position } | Record<string, never> =>
80
+ isRecord(value) && typeof value.x === 'number' && typeof value.y === 'number' ? { placed: { x: value.x, y: value.y } } : {};
81
+
82
+ /**
83
+ * A receipt's hash for a field Toghen owns but cannot prove Figma holds what it wrote, as after an older build: no
84
+ * report hashes to it, so the field reads as drift, kept and reported, until Figma holds the source's value again.
85
+ */
86
+ export const UNPROVEN = 'unproven';
87
+
88
+ /** A receipt from what Figma reports for each field, read back right after the write so a later read compares exactly. */
89
+ export function receiptOf(reported: Record<string, unknown>): Record<string, string> {
90
+ const receipt: Record<string, string> = {};
91
+ for (const [field, value] of Object.entries(reported)) receipt[field] = hashOf(value);
92
+ return receipt;
93
+ }
94
+
95
+ /** The object a stamp is written on: anything with shared plugin data and an id. */
96
+ export type Stamped = PluginDataMixin & { readonly id: string };
97
+
98
+ /** A stamp as it is kept on `object`: with the object's own id, whatever id `value` named. */
99
+ const textOf = (object: Stamped, value: Stamp): string => JSON.stringify({ ...value, id: object.id });
100
+
101
+ /** Writes the stamp, replacing the one there, with the object's own id. */
102
+ export function setStamp(object: Stamped, value: Stamp): void {
103
+ object.setSharedPluginData(NAMESPACE, STAMP_KEY, textOf(object, value));
104
+ }
105
+
106
+ /**
107
+ * The object's stamp, or `undefined` when it has none or one this code cannot read. Never throws. An object with no
108
+ * stamp of its own reads the one a dry run's migration would have written on it (`unwrittenStamps`).
109
+ */
110
+ export function getStamp(object: PluginDataMixin): Stamp | undefined {
111
+ try {
112
+ const text = object.getSharedPluginData(NAMESPACE, STAMP_KEY);
113
+ if (text === '') return getState().unwrittenStamps?.get((object as Partial<Stamped>).id ?? '');
114
+ const parsed = JSON.parse(text) as unknown;
115
+ if (!isRecord(parsed) || !isRecord(parsed.is) || !isRecord(parsed.receipt)) return undefined;
116
+ const is: Identity = {};
117
+ for (const facet of IDENTITY_TEXT) {
118
+ const text = parsed.is[facet];
119
+ if (typeof text === 'string') is[facet] = text;
120
+ }
121
+ if (parsed.is.base === true) is.base = true;
122
+ if (Object.keys(is).length === 0) return undefined;
123
+ const receipt: Record<string, string> = {};
124
+ for (const [field, hash] of Object.entries(parsed.receipt)) if (typeof hash === 'string') receipt[field] = hash;
125
+ return { is, ...(typeof parsed.id === 'string' ? { id: parsed.id } : {}), receipt, ...positionOf(parsed.placed) };
126
+ } catch (_unreadable) {
127
+ // No stamp reads as an empty string, which does not parse; a stamp someone edited by hand is not ours to interpret.
128
+ return undefined;
129
+ }
130
+ }
131
+
132
+ /** The keys components were stamped under before the stamp, read until a node's next write moves it over. */
133
+ const OLD_KEYS = ['component', 'properties', 'member', 'address', 'artboard', 'placed'] as const;
134
+
135
+ function oldKey(node: PluginDataMixin, key: (typeof OLD_KEYS)[number]): unknown {
136
+ const text = node.getSharedPluginData(NAMESPACE, key);
137
+ if (text === '' || key === 'component' || key === 'member' || key === 'artboard') return text === '' ? undefined : text;
138
+ try {
139
+ return JSON.parse(text);
140
+ } catch (_unreadable) {
141
+ return undefined;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * What a node's old keys say, as a stamp. A field or a property they list is owned, with no hash yet: the next run
147
+ * writes it, as the old code wrote a member's every run. An artboard's layer is the exception: the old code wrote it
148
+ * only when the file moved (its digest), so a field edited by hand since was kept. Its fields are `UNPROVEN`: the
149
+ * next run takes the receipt of each Figma holds the file's value in, and keeps any other as drift.
150
+ */
151
+ function fromOldKeys(node: PluginDataMixin): Stamp | undefined {
152
+ const is: Identity = {};
153
+ for (const facet of ['component', 'member', 'artboard'] as const) {
154
+ const text = oldKey(node, facet);
155
+ if (typeof text === 'string') is[facet] = text;
156
+ }
157
+ const layer = oldKey(node, 'address');
158
+ if (isRecord(layer) && typeof layer.address === 'string') is.address = layer.address;
159
+ if (Object.keys(is).length === 0) return undefined;
160
+ const properties = oldKey(node, 'properties');
161
+ const owned = [...(isRecord(layer) && Array.isArray(layer.fields) ? layer.fields : []), ...(Array.isArray(properties) ? properties : [])];
162
+ const hash = isRecord(layer) && typeof layer.digest === 'string' ? UNPROVEN : '';
163
+ const receipt: Record<string, string> = {};
164
+ for (const field of owned) if (typeof field === 'string') receipt[field] = hash;
165
+ return { is, receipt, ...positionOf(oldKey(node, 'placed')) };
166
+ }
167
+
168
+ /** A node's stamp, or what its old keys say. */
169
+ export function stampOn(node: PluginDataMixin): Stamp | undefined {
170
+ return getStamp(node) ?? fromOldKeys(node);
171
+ }
172
+
173
+ /** Writes a node's stamp over the one it has, keeping what `change` leaves out, and deletes the node's old keys. A stamp that would not change, its id included, is not written. */
174
+ export function updateStamp(node: Stamped, change: Partial<Stamp>): void {
175
+ const held = stampOn(node);
176
+ const placed = change.placed ?? held?.placed;
177
+ const next: Stamp = { is: { ...held?.is, ...change.is }, receipt: change.receipt ?? held?.receipt ?? {}, ...(placed ? { placed } : {}) };
178
+ // A stamp that names another id, or none, is written again with the node's.
179
+ if (node.getSharedPluginData(NAMESPACE, STAMP_KEY) !== textOf(node, next)) setStamp(node, next);
180
+ for (const key of OLD_KEYS) if (node.getSharedPluginData(NAMESPACE, key) !== '') node.setSharedPluginData(NAMESPACE, key, '');
181
+ }
182
+
183
+ /** What `findAllWithCriteria` finds a node stamped as `facet` by: its stamp, or the old key of that facet (either matches). */
184
+ export const stampedAs = (facet: 'component' | 'member' | 'artboard' | 'address'): { namespace: string; keys: string[] } => ({
185
+ namespace: NAMESPACE,
186
+ keys: [STAMP_KEY, facet],
187
+ });
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // a copy carries the stamp
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /**
194
+ * Whether a stamp is the object's own: written on it, by its id. A stamp written before the id was recorded has none,
195
+ * and is read as its own; the next write gives it the id (decisions, *Ruled: a copy carries the stamp*).
196
+ */
197
+ export const isOwn = (object: { readonly id: string }, stamp: Stamp): boolean => stamp.id === undefined || stamp.id === object.id;
198
+
199
+ /** Figma's ids in string order: which of several copies is taken for an original that is gone. */
200
+ const byId = (a: { readonly id: string }, b: { readonly id: string }): number => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
201
+
202
+ /** Stamped objects, sorted by `sortOut`. */
203
+ export interface Sorted<T> {
204
+ /** Toghen's, in the order given: each object whose stamp is its own, and each original that moved. */
205
+ ours: T[];
206
+ /** Those of `ours` whose stamp names another id: the original, moved (a cut and paste gives a node a new id). Its next write records its own. */
207
+ moved: Set<T>;
208
+ /** The copies a person made, each with the object it is a copy of. Never patched, never deleted, never counted as Toghen's. */
209
+ copies: Array<{ copy: T; of: T }>;
210
+ }
211
+
212
+ /**
213
+ * Tells Toghen's objects from the copies a person made of them: the one place that decides, and every reader that
214
+ * groups objects by their stamps goes through it.
215
+ *
216
+ * A copy carries the stamp of the object it was made from, id and all, so every object whose stamp names an id that is
217
+ * not its own claims to be the object with that id. Where that object exists and its stamp is its own, it is Toghen's
218
+ * and the claimants are its copies. Where it is gone, the claimant with the lowest Figma id, in string order, is the
219
+ * original moved, and the others are copies of it. `find` looks an object up by id wherever it is: an original may be on
220
+ * another page, or in another member, than its copy. Objects with no stamp are left out.
221
+ */
222
+ export async function sortOut<T extends { readonly id: string }>(
223
+ objects: readonly T[],
224
+ stampOf: (object: T) => Stamp | undefined,
225
+ find: (id: string) => T | null | undefined | Promise<T | null | undefined>,
226
+ ): Promise<Sorted<T>> {
227
+ const own = new Set<T>();
228
+ const claims = new Map<string, T[]>();
229
+ for (const object of objects) {
230
+ const stamp = stampOf(object);
231
+ if (!stamp) continue;
232
+ if (isOwn(object, stamp)) own.add(object);
233
+ else claims.set(stamp.id as string, [...(claims.get(stamp.id as string) ?? []), object]);
234
+ }
235
+ const moved = new Set<T>();
236
+ const copies: Sorted<T>['copies'] = [];
237
+ for (const [id, claimants] of claims) {
238
+ const found = await find(id);
239
+ const held = found ? stampOf(found) : undefined;
240
+ const rest = [...claimants].sort(byId);
241
+ let of = found && held && isOwn(found, held) ? found : undefined;
242
+ if (!of) {
243
+ of = rest.shift() as T;
244
+ own.add(of);
245
+ moved.add(of);
246
+ }
247
+ for (const copy of rest) copies.push({ copy, of });
248
+ }
249
+ return { ours: objects.filter((object) => own.has(object)), moved, copies };
250
+ }
251
+
252
+ /** `sortOut` for nodes, each found by id anywhere in the file. Every page must be loaded first. */
253
+ export const sortNodes = <T extends SceneNode>(nodes: readonly T[]): Promise<Sorted<T>> =>
254
+ sortOut(nodes, stampOn, async (id) => (await figma.getNodeByIdAsync(id)) as T | null);
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // a copy whose original Toghen removes
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /**
261
+ * Removes Toghen's stamp from an object, and nothing else of it: a person's copy whose original a run deletes becomes
262
+ * the person's own, never adopted as the original moved, never removed (decisions, *A copy whose original Toghen removes*).
263
+ */
264
+ export const strip = (object: PluginDataMixin): void => object.setSharedPluginData(NAMESPACE, STAMP_KEY, '');
265
+
266
+ /** Strips the nodes a deletion step carries, once the deletion has run. One already gone is passed over. */
267
+ export async function stripNodes(ids: readonly string[] = []): Promise<void> {
268
+ for (const id of ids) {
269
+ const node = await figma.getNodeByIdAsync(id);
270
+ if (node && !node.removed) strip(node as SceneNode);
271
+ }
272
+ }
273
+
274
+ /** What a deletion's row says of the copies it strips: `its copy "Footer" (12:3) keeps no stamp`. */
275
+ export const keepsNoStamp = (copies: ReadonlyArray<{ readonly id: string; readonly name: string }>): string =>
276
+ `its ${copies.length === 1 ? 'copy' : 'copies'} ${copies.map((copy) => `"${copy.name}" (${copy.id})`).join(', ')} ${copies.length === 1 ? 'keeps' : 'keep'} no stamp`;
277
+
278
+ /** Of these nodes, those inside none of the others: what a row names, since what is inside a copy goes with it. */
279
+ export function outermost<T extends BaseNode>(nodes: readonly T[]): T[] {
280
+ const ids = new Set(nodes.map((node) => node.id));
281
+ return nodes.filter((node) => {
282
+ for (let up = node.parent; up && up.type !== 'PAGE'; up = up.parent) if (ids.has(up.id)) return false;
283
+ return true;
284
+ });
285
+ }
286
+
287
+ /**
288
+ * Reads every page's stamped nodes once, and returns what finds the copies of a node a step deletes: each stamped node
289
+ * outside it whose stamp names the id of the node or of a node inside it (or an id one of their stamps names, for an
290
+ * original moved), grouped as `sortOut` groups them. A copied frame holds copies of the layers in it, so they are found
291
+ * too. `inside: false` is for a node whose children outlive it, as a replaced layer's do. Every page must be loaded.
292
+ */
293
+ export function copiesFinder(): (gone: SceneNode, inside?: boolean) => SceneNode[] {
294
+ const naming = new Map<string, SceneNode[]>();
295
+ for (const node of findInEveryPage({ sharedPluginData: { namespace: NAMESPACE, keys: [STAMP_KEY] } })) {
296
+ const stamp = getStamp(node);
297
+ if (stamp?.id !== undefined && stamp.id !== node.id) naming.set(stamp.id, [...(naming.get(stamp.id) ?? []), node]);
298
+ }
299
+ return (gone, inside = true) => {
300
+ const ids = new Set<string>();
301
+ const named = new Set<string>();
302
+ const walk = (node: BaseNode): void => {
303
+ ids.add(node.id);
304
+ named.add(node.id);
305
+ const id = getStamp(node as SceneNode)?.id;
306
+ if (id !== undefined) named.add(id);
307
+ if (inside && 'children' in node) for (const child of node.children) walk(child);
308
+ };
309
+ walk(gone);
310
+ return flatMap([...named], (id) => naming.get(id) ?? []).filter((copy) => !ids.has(copy.id));
311
+ };
312
+ }