@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,200 @@
1
+ import type { Operation, OperationResult, OperationSummary } from '../../shared/operations.js';
2
+ import { executeOperation, labelForOperation } from './operationExecutor.js';
3
+ import { SymbolicIdRegistry } from '../utils/symbolicIdRegistry.js';
4
+ import type { SimpleAbortSignal } from '../utils/abortController.js';
5
+ import { dependenciesOf } from '../utils/planSymbols.js';
6
+ import { isAlias } from '../utils/figmaTokenAdapter.js';
7
+
8
+ export interface BatchRunOptions {
9
+ onProgress: (completed: number, total: number, label: string) => void;
10
+ signal?: SimpleAbortSignal;
11
+ }
12
+
13
+ function resolveOperationIds(op: Operation, registry: SymbolicIdRegistry): Operation {
14
+ switch (op.kind) {
15
+ case 'ADD_MODE':
16
+ return { ...op, collectionId: registry.resolve(op.collectionId) };
17
+ case 'DELETE_MODE':
18
+ case 'DELETE_VARIABLE':
19
+ return op;
20
+ case 'CREATE_VARIABLE':
21
+ return { ...op, collectionId: registry.resolve(op.collectionId) };
22
+ case 'SET_VARIABLE_VALUE':
23
+ return {
24
+ ...op,
25
+ variableId: registry.resolve(op.variableId),
26
+ modeId: registry.resolve(op.modeId),
27
+ // An alias may point at a variable this same plan creates.
28
+ value: isAlias(op.value) ? { ...op.value, id: registry.resolve(op.value.id) } : op.value,
29
+ };
30
+ case 'BIND_VARIABLE':
31
+ return { ...op, variableId: registry.resolve(op.variableId) };
32
+ case 'RENAME_VARIABLE':
33
+ case 'SET_CODE_SYNTAX':
34
+ case 'SET_VARIABLE_SCOPES':
35
+ return { ...op, variableId: registry.resolve(op.variableId) };
36
+ case 'UNBIND_VARIABLE':
37
+ case 'APPLY_TOKEN':
38
+ case 'STAMP':
39
+ case 'CREATE_COLLECTION':
40
+ case 'CREATE_STYLE':
41
+ case 'UPDATE_STYLE':
42
+ case 'SKIP':
43
+ case 'WARNING':
44
+ case 'BREAKING':
45
+ case 'NOTE':
46
+ case 'SWAP_COMPONENT':
47
+ return op;
48
+ default: {
49
+ const _exhaustive: never = op;
50
+ return _exhaustive;
51
+ }
52
+ }
53
+ }
54
+
55
+ async function registerSymbolicOutputs(originalOp: Operation, figmaId: string | undefined, registry: SymbolicIdRegistry): Promise<void> {
56
+ if (!figmaId) return;
57
+
58
+ if (originalOp.kind === 'CREATE_COLLECTION' && originalOp.symbolicId) {
59
+ registry.register(originalOp.symbolicId, figmaId);
60
+
61
+ const collection = await figma.variables.getVariableCollectionByIdAsync(figmaId);
62
+ if (collection) {
63
+ for (const mode of collection.modes) {
64
+ registry.register(`$mode:${originalOp.name}/${mode.name}`, mode.modeId);
65
+ }
66
+ }
67
+ return;
68
+ }
69
+
70
+ if (originalOp.kind === 'ADD_MODE') {
71
+ const collection = await figma.variables.getVariableCollectionByIdAsync(figmaId);
72
+ const mode = collection?.modes.find((m) => m.name === originalOp.modeName);
73
+ if (collection && mode) {
74
+ registry.register(`$mode:${collection.name}/${originalOp.modeName}`, mode.modeId);
75
+ }
76
+ return;
77
+ }
78
+
79
+ if (originalOp.kind === 'CREATE_VARIABLE' && originalOp.symbolicId) {
80
+ registry.register(originalOp.symbolicId, figmaId);
81
+ }
82
+ }
83
+
84
+ export async function runOperations(ops: Operation[], options: BatchRunOptions): Promise<OperationSummary> {
85
+ const results: OperationResult[] = [];
86
+ const startTime = Date.now();
87
+ const registry = new SymbolicIdRegistry();
88
+
89
+ /** The plan's rows that are not skips: carried into the results, and counted by the plan, not as skipped here. */
90
+ const isCounted = (op: Operation) => op.kind === 'WARNING' || op.kind === 'BREAKING' || op.kind === 'NOTE';
91
+ const isRow = (op: Operation) => op.kind === 'SKIP' || isCounted(op);
92
+ const executable = ops.filter((op) => !isRow(op));
93
+ const skipped = ops.filter(isRow);
94
+
95
+ skipped.forEach((op) => {
96
+ results.push({
97
+ operation: op,
98
+ status: 'skipped',
99
+ reason: op.kind === 'SKIP' ? op.reason : op.message,
100
+ });
101
+ });
102
+
103
+ // Symbols whose producing operation failed. Everything downstream of one is
104
+ // reported as a skipped prerequisite rather than as its own failure — a
105
+ // CREATE_COLLECTION that hits the mode cap used to cascade into hundreds of
106
+ // identical "Unresolved symbolic ID" errors that said nothing about the cause.
107
+ const failedSymbols = new Set<string>();
108
+
109
+ for (let i = 0; i < executable.length; i += 1) {
110
+ const operation = executable[i];
111
+
112
+ if (options.signal?.aborted) {
113
+ results.push({
114
+ operation,
115
+ status: 'skipped',
116
+ reason: 'cancelled',
117
+ });
118
+ continue;
119
+ }
120
+
121
+ const blockedBy = blockingSymbol(operation, failedSymbols);
122
+ if (blockedBy) {
123
+ results.push({
124
+ operation,
125
+ status: 'skipped',
126
+ reason: `prerequisite failed: ${blockedBy}`,
127
+ });
128
+ recordFailedSymbol(operation, failedSymbols);
129
+ continue;
130
+ }
131
+
132
+ let resolvedOperation: Operation;
133
+ try {
134
+ resolvedOperation = resolveOperationIds(operation, registry);
135
+ } catch (error) {
136
+ const reason = error instanceof Error ? error.message : String(error);
137
+ results.push({
138
+ operation,
139
+ status: 'failed',
140
+ reason,
141
+ });
142
+ recordFailedSymbol(operation, failedSymbols);
143
+ continue;
144
+ }
145
+
146
+ options.onProgress(i + 1, executable.length, labelForOperation(resolvedOperation));
147
+
148
+ const opStart = Date.now();
149
+ try {
150
+ const figmaId = await executeOperation(resolvedOperation);
151
+ results.push({
152
+ operation: resolvedOperation,
153
+ status: 'applied',
154
+ figmaId,
155
+ durationMs: Date.now() - opStart,
156
+ });
157
+
158
+ await registerSymbolicOutputs(operation, figmaId, registry);
159
+ } catch (error) {
160
+ const reason = error instanceof Error ? error.message : String(error);
161
+ results.push({
162
+ operation: resolvedOperation,
163
+ status: 'failed',
164
+ reason,
165
+ durationMs: Date.now() - opStart,
166
+ });
167
+ recordFailedSymbol(operation, failedSymbols);
168
+ }
169
+ }
170
+
171
+ return {
172
+ total: ops.length,
173
+ applied: results.filter((r) => r.status === 'applied').length,
174
+ // A skip, a prerequisite that failed, a cancellation: a warning, a note or a breaking row was counted by the plan under its own name (R14).
175
+ skipped: results.filter((r) => r.status === 'skipped' && !isCounted(r.operation)).length,
176
+ failed: results.filter((r) => r.status === 'failed').length,
177
+ cancelled: results.filter((r) => r.status === 'skipped' && r.reason === 'cancelled').length,
178
+ durationMs: Date.now() - startTime,
179
+ results,
180
+ };
181
+ }
182
+
183
+ /** The symbolic id an operation produces, if any. */
184
+ function producedSymbol(op: Operation): string | undefined {
185
+ if (op.kind === 'CREATE_COLLECTION' || op.kind === 'CREATE_VARIABLE') {
186
+ return op.symbolicId;
187
+ }
188
+ return undefined;
189
+ }
190
+
191
+ /** Records that an operation's output symbol will never resolve. */
192
+ function recordFailedSymbol(op: Operation, failedSymbols: Set<string>): void {
193
+ const symbol = producedSymbol(op);
194
+ if (symbol) failedSymbols.add(symbol);
195
+ }
196
+
197
+ /** The failed symbol an operation depends on, if any. */
198
+ function blockingSymbol(op: Operation, failedSymbols: Set<string>): string | undefined {
199
+ return dependenciesOf(op).find((dependency) => failedSymbols.has(dependency));
200
+ }
@@ -0,0 +1,132 @@
1
+ import type { DesignSystem } from '@toghen/lib';
2
+ import type { Stamp } from '../components/stamp.js';
3
+ import { flattenDesignSystem } from '../utils/flattenDesignSystem.js';
4
+ import { referencePath } from '@toghen/lib';
5
+
6
+ /** Namespace this plugin owns inside a token's `$extensions`. */
7
+ const NAMESPACE = 'com.toghen.figma';
8
+
9
+ /** Namespace for facts about the document rather than about one token. */
10
+ const DOCUMENT_NAMESPACE = 'com.toghen';
11
+
12
+ export interface ExportOptions {
13
+ includeExtensions: boolean;
14
+ /** Mode to export. Omitted for a document with a single mode. */
15
+ mode?: string;
16
+ /**
17
+ * The version the design system declares for itself, if it declares one.
18
+ *
19
+ * Carried through so the export says which revision it came from. Without it
20
+ * the provenance stopped at the resolver document: the token file named the
21
+ * mode in its filename and nothing at all about the system.
22
+ */
23
+ documentVersion?: string;
24
+ }
25
+
26
+ /**
27
+ * Writes the design system out as DTCG JSON. `stamps` are what the variables and styles of this file carry, by the
28
+ * token each names: written out under the token's `$extensions` for reading only, since a target's ids and
29
+ * receipts belong to one file and loading a document never reads them back.
30
+ */
31
+ export function exportToJSON(ds: DesignSystem, stamps: ReadonlyMap<string, Stamp>, options: ExportOptions): string {
32
+ const output: Record<string, unknown> = {};
33
+
34
+ /*
35
+ * Provenance first, so it reads at the top of the file.
36
+ *
37
+ * §6.3.2: "Groups MAY include an optional `$extensions` property where tools
38
+ * MAY add proprietary, user-, team- or vendor-specific data." A token file's
39
+ * root is a group — §6.1 identifies a group as any object without `$value` —
40
+ * so this is where a fact about the whole document belongs.
41
+ *
42
+ * It cannot collide with a token: §5.1 requires that token and group names
43
+ * MUST NOT begin with `$`.
44
+ *
45
+ * Gated on the same switch as per-token extensions. The control says
46
+ * "Include $extensions", and writing vendor data into a file where the user
47
+ * asked for none would contradict it.
48
+ */
49
+ if (options.includeExtensions && options.documentVersion) {
50
+ output.$extensions = {
51
+ [DOCUMENT_NAMESPACE]: { version: options.documentVersion },
52
+ };
53
+ }
54
+
55
+ for (const { path, token } of flattenDesignSystem(ds, options.mode)) {
56
+ const tokenObj: Record<string, unknown> = {
57
+ $value: token.$value,
58
+ $type: token.$type,
59
+ };
60
+
61
+ if (token.$description) {
62
+ tokenObj.$description = token.$description;
63
+ }
64
+
65
+ if (options.includeExtensions) {
66
+ const stamp = stamps.get(path);
67
+ const existingExtensions = token.$extensions ?? {};
68
+
69
+ if (Object.keys(existingExtensions).length > 0 || stamp) {
70
+ // Merged *into* whatever the token already carries under our namespace,
71
+ // not over it. Replacing the object wholesale dropped any key another
72
+ // build had written there.
73
+ const existingOurs = existingExtensions[NAMESPACE];
74
+ tokenObj.$extensions = {
75
+ ...existingExtensions,
76
+ ...(stamp
77
+ ? {
78
+ [NAMESPACE]: {
79
+ ...(isPlainObject(existingOurs) ? existingOurs : {}),
80
+ stamp,
81
+ },
82
+ }
83
+ : {}),
84
+ };
85
+ }
86
+ }
87
+
88
+ // The collection is where the tokens live, not a group of them: without it the
89
+ // file is a DTCG document again, and loads back as one.
90
+ setNestedPath(output, referencePath(path), tokenObj);
91
+ }
92
+
93
+ return JSON.stringify(output, null, 2);
94
+ }
95
+
96
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
97
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
98
+ }
99
+
100
+ /**
101
+ * Writes `value` at a dotted path, refusing to overwrite anything on the way.
102
+ *
103
+ * A backstop, not the enforcement: `Collection.setToken()` rejects a node that
104
+ * is both a token and a group, so a `DesignSystem` cannot legally hold this
105
+ * shape. It stays because the old code responded to a collision by replacing
106
+ * whatever it found with an empty object — the token already written at the
107
+ * shorter path vanished from the export without a word, and the export is what
108
+ * a user reloads next session.
109
+ */
110
+ function setNestedPath(obj: Record<string, unknown>, path: string, value: unknown): void {
111
+ const parts = path.split('.');
112
+ let current: Record<string, unknown> = obj;
113
+
114
+ for (let i = 0; i < parts.length - 1; i += 1) {
115
+ const key = parts[i];
116
+ const next = current[key];
117
+
118
+ if (next === undefined) {
119
+ current[key] = {};
120
+ } else if (!isPlainObject(next) || '$value' in next) {
121
+ throw new Error(`Cannot export "${path}": "${parts.slice(0, i + 1).join('.')}" is already a token, so it cannot also be a group.`);
122
+ }
123
+
124
+ current = current[key] as Record<string, unknown>;
125
+ }
126
+
127
+ const leaf = parts[parts.length - 1];
128
+ if (current[leaf] !== undefined) {
129
+ throw new Error(`Cannot export "${path}": it is both a token and a group.`);
130
+ }
131
+ current[leaf] = value;
132
+ }