@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.
- package/LICENSE +21 -0
- package/README.md +18 -2
- package/cli/bin.mjs +19 -0
- package/cli/bridge.ts +243 -0
- package/cli/check.ts +62 -0
- package/cli/figcraft.ts +764 -0
- package/cli/figcraftApply.ts +1067 -0
- package/cli/figcraftFile.ts +476 -0
- package/cli/figcraftLibrary.ts +147 -0
- package/cli/figcraftRecord.ts +121 -0
- package/cli/figcraftScene.ts +661 -0
- package/cli/main.ts +218 -0
- package/cli/members.ts +65 -0
- package/cli/run.ts +756 -0
- package/component-files.md +665 -0
- package/dist/code.js +24161 -0
- package/dist/manifest.json +23 -0
- package/dist/ui.html +6918 -0
- package/guide.md +985 -0
- package/main/code.ts +50 -0
- package/main/components/apply.ts +704 -0
- package/main/components/endOfRun.ts +168 -0
- package/main/components/errors.ts +17 -0
- package/main/components/fieldWriter.ts +497 -0
- package/main/components/file.ts +263 -0
- package/main/components/receipt.ts +228 -0
- package/main/components/resolve.ts +1193 -0
- package/main/components/run.ts +273 -0
- package/main/components/snapshot.ts +341 -0
- package/main/components/stamp.ts +312 -0
- package/main/components/update.ts +465 -0
- package/main/dispatcher.ts +33 -0
- package/main/engines/batchRunner.ts +200 -0
- package/main/engines/ioAdapter.ts +132 -0
- package/main/engines/operationExecutor.ts +512 -0
- package/main/engines/propertyMapper.ts +221 -0
- package/main/engines/queryEngine.ts +45 -0
- package/main/engines/styleSyncEngine.ts +444 -0
- package/main/engines/variableSyncEngine.ts +561 -0
- package/main/handlers/auditSelection.ts +60 -0
- package/main/handlers/boundLayers.ts +51 -0
- package/main/handlers/cancelOperation.ts +18 -0
- package/main/handlers/executeApplyBindings.ts +69 -0
- package/main/handlers/executeScaffold.ts +59 -0
- package/main/handlers/executeStyleSync.ts +121 -0
- package/main/handlers/executeVariableSync.ts +82 -0
- package/main/handlers/exportDocument.ts +114 -0
- package/main/handlers/getSelection.ts +43 -0
- package/main/handlers/index.ts +30 -0
- package/main/handlers/inspectCorrespondence.ts +172 -0
- package/main/handlers/loadConventions.ts +86 -0
- package/main/handlers/loadDocument.ts +253 -0
- package/main/handlers/lock.ts +270 -0
- package/main/handlers/planApply.ts +157 -0
- package/main/handlers/planApplyBindings.ts +79 -0
- package/main/handlers/planScaffold.ts +84 -0
- package/main/handlers/planStyleSync.ts +49 -0
- package/main/handlers/planVariableSync.ts +49 -0
- package/main/handlers/readSelectionBindings.ts +160 -0
- package/main/handlers/resizeUi.ts +105 -0
- package/main/handlers/runComponent.ts +65 -0
- package/main/handlers/templates.ts +462 -0
- package/main/handlers/tokenGraph.ts +122 -0
- package/main/runtimeState.ts +66 -0
- package/main/utils/abortController.ts +11 -0
- package/main/utils/boundVariables.ts +84 -0
- package/main/utils/buildPayloads.ts +85 -0
- package/main/utils/correspondence.ts +658 -0
- package/main/utils/designSource.ts +122 -0
- package/main/utils/figmaCollectionIndex.ts +120 -0
- package/main/utils/figmaStyleAdapter.ts +268 -0
- package/main/utils/figmaTokenAdapter.ts +255 -0
- package/main/utils/flattenDesignSystem.ts +38 -0
- package/main/utils/folds.ts +203 -0
- package/main/utils/fontStack.ts +57 -0
- package/main/utils/lists.ts +4 -0
- package/main/utils/lockStore.ts +72 -0
- package/main/utils/moves.ts +67 -0
- package/main/utils/pageRoots.ts +58 -0
- package/main/utils/planSymbols.ts +96 -0
- package/main/utils/purpose.ts +351 -0
- package/main/utils/recentStore.ts +166 -0
- package/main/utils/resolverModes.ts +271 -0
- package/main/utils/rootRecord.ts +137 -0
- package/main/utils/shardedData.ts +142 -0
- package/main/utils/standing.ts +106 -0
- package/main/utils/styleTarget.ts +51 -0
- package/main/utils/sum.ts +612 -0
- package/main/utils/symbolicIdRegistry.ts +26 -0
- package/main/utils/syncState.ts +133 -0
- package/main/utils/templateStore.ts +73 -0
- package/main/utils/tokenPath.ts +28 -0
- package/main/utils/tokenStamps.ts +91 -0
- package/manifest.json +15 -0
- package/package.json +64 -4
- package/reference.md +1609 -0
- package/shared/components/artboard.ts +126 -0
- package/shared/components/axes.ts +420 -0
- package/shared/components/bindings.ts +64 -0
- package/shared/components/blueprint.ts +248 -0
- package/shared/components/check.ts +705 -0
- package/shared/components/diff.ts +668 -0
- package/shared/components/fields.ts +404 -0
- package/shared/components/groups.ts +306 -0
- package/shared/components/index.ts +140 -0
- package/shared/components/members.ts +280 -0
- package/shared/components/model.ts +273 -0
- package/shared/components/order.ts +53 -0
- package/shared/components/read.ts +480 -0
- package/shared/components/stack.ts +124 -0
- package/shared/components/steps.ts +186 -0
- package/shared/components/validate.ts +565 -0
- package/shared/conventions.ts +28 -0
- package/shared/inputs.ts +40 -0
- package/shared/operations.ts +233 -0
- package/shared/pendingNames.ts +39 -0
- package/shared/protocol.ts +588 -0
- package/shared/tokenTypeMap.ts +21 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lock, written from the plugin: variables by discovery, a component or a style by a person picking it once,
|
|
3
|
+
* forgetting a library, and Build's overrides map. See `specs.md`, *The lock*.
|
|
4
|
+
*
|
|
5
|
+
* The Plugin API lists a library's variables, but not its components or styles: those import by key only. So
|
|
6
|
+
* discovery fills variables, and a component or a style is picked from what a person placed from the assets panel.
|
|
7
|
+
* The library file knows its own keys, though: the command line, paired with a plugin in each file, asks the library's
|
|
8
|
+
* for them (`LIBRARY_KEYS`) and has the target's write them (`LOCK_WRITE`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Command, LibraryKeys, Lock, LockedLibrary, Overrides, Response } from '../../shared/protocol.js';
|
|
12
|
+
import type { Operation } from '../../shared/operations.js';
|
|
13
|
+
import { isBlueprintFile } from '../../shared/components/index.js';
|
|
14
|
+
import type { Member } from '../../shared/components/index.js';
|
|
15
|
+
import { readBlueprintText, readComponentText } from '../components/file.js';
|
|
16
|
+
import { namedTokens, variableSlots } from '../components/resolve.js';
|
|
17
|
+
import { sortNodes, stampOn, stampedAs } from '../components/stamp.js';
|
|
18
|
+
import { isLibrary, libraryIn, loadLock, saveLock } from '../utils/lockStore.js';
|
|
19
|
+
import { findInEveryPage } from '../utils/pageRoots.js';
|
|
20
|
+
import { localStyles } from '../utils/tokenStamps.js';
|
|
21
|
+
import { loadTemplates } from '../utils/templateStore.js';
|
|
22
|
+
|
|
23
|
+
type Payload<T extends Command['type']> = Extract<Command, { type: T }>['payload'];
|
|
24
|
+
|
|
25
|
+
export function GET_LOCK(): void {
|
|
26
|
+
postToUi({ type: 'LOCK', payload: loadLock() });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* For each token the kept files bind, the collection and name the variable sync gives it, matched by both names
|
|
31
|
+
* against the variables of the enabled libraries. A match in one library is written under its name; a match in two
|
|
32
|
+
* is a choice, listed and never picked (R10). One row per variable found, per choice, and per token not found (R14).
|
|
33
|
+
*/
|
|
34
|
+
export async function DISCOVER_VARIABLES(): Promise<void> {
|
|
35
|
+
const slotOf = variableSlots();
|
|
36
|
+
if (!slotOf) {
|
|
37
|
+
postToUi({
|
|
38
|
+
type: 'ERROR',
|
|
39
|
+
payload: {
|
|
40
|
+
message: 'Load the design system first: discovery looks for each token by the collection and the name the variable sync gives it.',
|
|
41
|
+
code: 'NO_RESOLVER',
|
|
42
|
+
commandType: 'DISCOVER_VARIABLES',
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The tokens the kept files bind, each once, in the order first met.
|
|
49
|
+
const paths = new Set<string>();
|
|
50
|
+
for (const template of loadTemplates()) {
|
|
51
|
+
const members: Member[] = isBlueprintFile(template.fileName)
|
|
52
|
+
? ([] as Member[]).concat(...readBlueprintText(template.text).bodies.map((body) => body.members))
|
|
53
|
+
: readComponentText(template.text).members;
|
|
54
|
+
const named = namedTokens(members);
|
|
55
|
+
for (const path of [...named.floats.keys(), ...named.colors.keys()]) paths.add(path);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Each enabled library's collections, and a collection's variables read once.
|
|
59
|
+
const collections = await figma.teamLibrary.getAvailableLibraryVariableCollectionsAsync();
|
|
60
|
+
const read = new Map<string, LibraryVariable[]>();
|
|
61
|
+
const variablesOf = async (key: string): Promise<LibraryVariable[]> => {
|
|
62
|
+
if (!read.has(key)) read.set(key, await figma.teamLibrary.getVariablesInLibraryCollectionAsync(key));
|
|
63
|
+
return read.get(key)!;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const lock = loadLock();
|
|
67
|
+
const overrides = lock.overrides ?? {};
|
|
68
|
+
const found = new Map<string, Record<string, string>>();
|
|
69
|
+
const operations: Operation[] = [];
|
|
70
|
+
const counts = { found: 0, choices: 0, 'not found': 0 };
|
|
71
|
+
for (const path of paths) {
|
|
72
|
+
const slot = slotOf(path);
|
|
73
|
+
if (!slot) {
|
|
74
|
+
operations.push({
|
|
75
|
+
kind: 'NOTE',
|
|
76
|
+
message: `{${path}} is not a variable of the loaded design system, so no library was looked in for it.`,
|
|
77
|
+
});
|
|
78
|
+
counts['not found'] += 1;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const id = `${slot.collection}/${slot.name}`;
|
|
82
|
+
const matches: { library: string; key: string }[] = [];
|
|
83
|
+
for (const collection of collections) {
|
|
84
|
+
if (collection.name !== slot.collection) continue;
|
|
85
|
+
for (const variable of await variablesOf(collection.key))
|
|
86
|
+
if (variable.name === slot.name) matches.push({ library: collection.libraryName, key: variable.key });
|
|
87
|
+
}
|
|
88
|
+
// A name that was a match before and is a choice now is no longer the lock's to answer.
|
|
89
|
+
if (matches.length !== 1) for (const library of Object.values(lock.libraries)) delete library.variables[id];
|
|
90
|
+
if (matches.length === 0) {
|
|
91
|
+
operations.push({ kind: 'NOTE', message: `{${path}}: no enabled library has "${id}".` });
|
|
92
|
+
counts['not found'] += 1;
|
|
93
|
+
} else if (matches.length === 1) {
|
|
94
|
+
const into = found.get(matches[0].library) ?? found.set(matches[0].library, {}).get(matches[0].library)!;
|
|
95
|
+
into[id] = matches[0].key;
|
|
96
|
+
operations.push({ kind: 'NOTE', message: `{${path}}: "${id}" in library "${matches[0].library}".` });
|
|
97
|
+
counts.found += 1;
|
|
98
|
+
} else {
|
|
99
|
+
const libraries = matches.map((match) => `"${match.library}"`).join(' and ');
|
|
100
|
+
operations.push(
|
|
101
|
+
overrides[path] !== undefined
|
|
102
|
+
? { kind: 'NOTE', message: `{${path}}: "${id}" is in ${libraries}, and the overrides map names the one to use.` }
|
|
103
|
+
: {
|
|
104
|
+
kind: 'WARNING',
|
|
105
|
+
path,
|
|
106
|
+
message: `"${id}" is in ${libraries}: a choice, so nothing was written for it. Name the one to use in the overrides map, as (key)"…".`,
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
counts.choices += 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (paths.size === 0) operations.push({ kind: 'NOTE', message: 'No kept file binds a token, so there was nothing to look for.' });
|
|
113
|
+
|
|
114
|
+
for (const [name, variables] of found) {
|
|
115
|
+
const library = libraryIn(lock, name, 'discovery');
|
|
116
|
+
lock.libraries[name] = { ...library, by: 'discovery', variables: { ...library.variables, ...variables } };
|
|
117
|
+
}
|
|
118
|
+
const saved = saveLock(lock);
|
|
119
|
+
if (!saved.ok) {
|
|
120
|
+
postToUi({ type: 'ERROR', payload: { message: saved.reason, code: 'FIGMA_API_ERROR', commandType: 'DISCOVER_VARIABLES' } });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
postToUi({ type: 'PLAN_READY', payload: { planType: 'discovery', operations, summary: counts } });
|
|
124
|
+
GET_LOCK();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Writes what one selected layer names from a library, under the library the command names: the Plugin API gives
|
|
129
|
+
* no library's name for a remote component or style. An instance's component (its set, when it is a variant of
|
|
130
|
+
* one), and the layer's text and effect styles, each only when it is remote.
|
|
131
|
+
*/
|
|
132
|
+
export async function LOCK_PICK(payload: Payload<'LOCK_PICK'>): Promise<void> {
|
|
133
|
+
const refuse = (message: string): void =>
|
|
134
|
+
postToUi({ type: 'ERROR', payload: { message, code: 'INVALID_PICK', commandType: 'LOCK_PICK' } });
|
|
135
|
+
const name = payload.library.trim();
|
|
136
|
+
if (name === '') return refuse('Name the library the selected layer comes from: Figma does not say it to a plugin.');
|
|
137
|
+
const selection = figma.currentPage.selection;
|
|
138
|
+
if (selection.length !== 1)
|
|
139
|
+
return refuse(
|
|
140
|
+
`Select one layer: an instance placed from a library, or a layer with a library style. ${selection.length} are selected.`,
|
|
141
|
+
);
|
|
142
|
+
const node = selection[0];
|
|
143
|
+
|
|
144
|
+
const lock = loadLock();
|
|
145
|
+
const library: LockedLibrary = { ...libraryIn(lock, name, 'person'), by: 'person' };
|
|
146
|
+
library.components = { ...library.components };
|
|
147
|
+
library.styles = { ...library.styles };
|
|
148
|
+
let picked = 0;
|
|
149
|
+
|
|
150
|
+
if (node.type === 'INSTANCE') {
|
|
151
|
+
const main = await node.getMainComponentAsync();
|
|
152
|
+
if (main?.remote) {
|
|
153
|
+
// unmeasured: whether a library variant read from an instance has its set as its parent; the typings say a remote component may have none
|
|
154
|
+
const set = main.parent?.type === 'COMPONENT_SET' ? main.parent : undefined;
|
|
155
|
+
library.components[set ? set.name : main.name] = { key: set ? set.key : main.key, set: set !== undefined };
|
|
156
|
+
picked += 1;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const styles: [string, 'TEXT' | 'EFFECT'][] = [
|
|
160
|
+
['textStyleId', 'TEXT'],
|
|
161
|
+
['effectStyleId', 'EFFECT'],
|
|
162
|
+
];
|
|
163
|
+
for (const [field, kind] of styles) {
|
|
164
|
+
const id = field in node ? (node as unknown as Record<string, unknown>)[field] : undefined;
|
|
165
|
+
const style = typeof id === 'string' && id !== '' ? await figma.getStyleByIdAsync(id) : null;
|
|
166
|
+
if (!style?.remote) continue;
|
|
167
|
+
// A style is kept by the token path it is named from, as the style sync names it.
|
|
168
|
+
library.styles[style.name.split('/').join('.')] = { key: style.key, kind };
|
|
169
|
+
picked += 1;
|
|
170
|
+
}
|
|
171
|
+
if (picked === 0)
|
|
172
|
+
return refuse(
|
|
173
|
+
`"${node.name}" holds nothing from a library: select an instance placed from Figma's assets panel, or a layer with a library's text or effect style.`,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
lock.libraries[name] = library;
|
|
177
|
+
save(lock, 'LOCK_PICK');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function LOCK_FORGET(payload: Payload<'LOCK_FORGET'>): void {
|
|
181
|
+
const lock = loadLock();
|
|
182
|
+
delete lock.libraries[payload.library];
|
|
183
|
+
save(lock, 'LOCK_FORGET');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function SET_BUILD_OVERRIDES(payload: Payload<'SET_BUILD_OVERRIDES'>): void {
|
|
187
|
+
const lock = loadLock();
|
|
188
|
+
const overrides: Overrides = {};
|
|
189
|
+
for (const [name, to] of Object.entries(payload.overrides ?? {}))
|
|
190
|
+
if (typeof to === 'string' && name !== '' && to !== '') overrides[name] = to;
|
|
191
|
+
if (Object.keys(overrides).length > 0) lock.overrides = overrides;
|
|
192
|
+
else delete lock.overrides;
|
|
193
|
+
save(lock, 'SET_BUILD_OVERRIDES');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* In a library file: what it knows of its own keys, for the command line to copy into a target file's lock (the
|
|
198
|
+
* third filler). Each stamped set and each style the style sync stamped, every local variable, each with its publish
|
|
199
|
+
* status, and the file's key where `figma.fileKey` reads: only a private plugin, or a development one with
|
|
200
|
+
* `enablePrivatePluginApi` in its manifest. A published plugin would lose it.
|
|
201
|
+
*/
|
|
202
|
+
export async function LIBRARY_KEYS(): Promise<void> {
|
|
203
|
+
const keys: LibraryKeys = { components: {}, styles: {}, variables: {} };
|
|
204
|
+
const warnings: string[] = [];
|
|
205
|
+
const fileKey = figma.fileKey;
|
|
206
|
+
if (typeof fileKey === 'string' && fileKey !== '') keys.fileKey = fileKey;
|
|
207
|
+
|
|
208
|
+
// A person's copy of a set carries its stamp and is not the library's set (`sortNodes`).
|
|
209
|
+
for (const page of figma.root.children) await page.loadAsync();
|
|
210
|
+
// Only local objects are asked their publish status: an imported one throws or reads `UNPUBLISHED` (§15, second pass).
|
|
211
|
+
const stamped = await sortNodes(findInEveryPage({ types: ['COMPONENT_SET', 'COMPONENT'], sharedPluginData: stampedAs('component') }));
|
|
212
|
+
for (const node of stamped.ours) {
|
|
213
|
+
const name = stampOn(node)?.is.component;
|
|
214
|
+
if (name === undefined || name in keys.components) continue;
|
|
215
|
+
keys.components[name] = { key: node.key, set: node.type === 'COMPONENT_SET', status: await node.getPublishStatusAsync() };
|
|
216
|
+
}
|
|
217
|
+
// A style is kept by the token path its name spells, as a person's pick keeps one.
|
|
218
|
+
for (const { style, styleType, stamp } of await localStyles()) {
|
|
219
|
+
if (!stamp) continue;
|
|
220
|
+
// unmeasured in the desktop plugin: the connector's runtime had no `getPublishStatusAsync` on any style (§15, second
|
|
221
|
+
// pass), although the typings give every style one through `PublishableMixin`. Called where it exists, and said where not.
|
|
222
|
+
const read = (style as { getPublishStatusAsync?: unknown }).getPublishStatusAsync;
|
|
223
|
+
const path = style.name.split('/').join('.');
|
|
224
|
+
const status = typeof read === 'function' ? await style.getPublishStatusAsync() : undefined;
|
|
225
|
+
if (status === undefined)
|
|
226
|
+
warnings.push(
|
|
227
|
+
`The publish status of the ${styleType.toLowerCase()} style "${style.name}" could not be read: this Figma gives styles no getPublishStatusAsync.`,
|
|
228
|
+
);
|
|
229
|
+
keys.styles[path] = { key: style.key, kind: styleType, status };
|
|
230
|
+
}
|
|
231
|
+
const collections = new Map(
|
|
232
|
+
(await figma.variables.getLocalVariableCollectionsAsync()).map((collection) => [collection.id, collection.name]),
|
|
233
|
+
);
|
|
234
|
+
for (const variable of await figma.variables.getLocalVariablesAsync()) {
|
|
235
|
+
const collection = collections.get(variable.variableCollectionId);
|
|
236
|
+
if (collection !== undefined)
|
|
237
|
+
keys.variables[`${collection}/${variable.name}`] = { key: variable.key, status: await variable.getPublishStatusAsync() };
|
|
238
|
+
}
|
|
239
|
+
if (warnings.length > 0) keys.warnings = warnings;
|
|
240
|
+
postToUi({ type: 'LIBRARY_KEYS_READ', payload: keys });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** In a target file: one library's entry, as the command line read it from the library file, written over the one there. */
|
|
244
|
+
export function LOCK_WRITE(payload: Payload<'LOCK_WRITE'>): void {
|
|
245
|
+
const name = typeof payload.library === 'string' ? payload.library.trim() : '';
|
|
246
|
+
if (name === '' || !isLibrary(payload.entry)) {
|
|
247
|
+
postToUi({
|
|
248
|
+
type: 'ERROR',
|
|
249
|
+
payload: {
|
|
250
|
+
message: "LOCK_WRITE needs a library name and an entry in the lock's shape.",
|
|
251
|
+
code: 'INVALID_PICK',
|
|
252
|
+
commandType: 'LOCK_WRITE',
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const lock = loadLock();
|
|
258
|
+
lock.libraries[name] = payload.entry;
|
|
259
|
+
save(lock, 'LOCK_WRITE');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function save(lock: Lock, commandType: string): void {
|
|
263
|
+
const saved = saveLock(lock);
|
|
264
|
+
if (!saved.ok) postToUi({ type: 'ERROR', payload: { message: saved.reason, code: 'FIGMA_API_ERROR', commandType } });
|
|
265
|
+
else GET_LOCK();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function postToUi(response: Response): void {
|
|
269
|
+
figma.ui.postMessage(response);
|
|
270
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply's plan: a token, or a group's tokens, on the layers of a scope. See `reference.md`, *Apply a token to layers*.
|
|
3
|
+
*
|
|
4
|
+
* The query is over tokens, not layer names: a token's path, or a group's, which is a set. A set reaches fields by each
|
|
5
|
+
* token's last key, through the table `tokens=` uses in a component file (`planOf`); one token takes the field it is
|
|
6
|
+
* given, or reaches one by its last key as a set's member does. One operation per layer and field; a field the layer
|
|
7
|
+
* has not, or a token the file has no variable for, is a row saying so. What is written as a value is said here,
|
|
8
|
+
* before Execute, with the context it takes: the declared defaults.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { FIELDS, namesStyle } from '../../shared/components/fields.js';
|
|
12
|
+
import { planOf } from '../../shared/components/groups.js';
|
|
13
|
+
import type { GroupInfo, Laid } from '../../shared/components/model.js';
|
|
14
|
+
import type { Operation } from '../../shared/operations.js';
|
|
15
|
+
import type { ApplyKind, Command, Response } from '../../shared/protocol.js';
|
|
16
|
+
import { applyKindOf, applyLayer, resolvedFor, valueLine } from '../components/fieldWriter.js';
|
|
17
|
+
import { coreAtDefaults } from '../components/resolve.js';
|
|
18
|
+
import { QueryEngine } from '../engines/queryEngine.js';
|
|
19
|
+
import { getState, setState } from '../runtimeState.js';
|
|
20
|
+
import { designSourceOf } from '../utils/designSource.js';
|
|
21
|
+
import { tokenPathToVariableName } from '../utils/tokenPath.js';
|
|
22
|
+
|
|
23
|
+
type Payload = Extract<Command, { type: 'PLAN_APPLY' }>['payload'];
|
|
24
|
+
|
|
25
|
+
/** The Figma layer types each kind takes. */
|
|
26
|
+
const TYPES: Record<ApplyKind, readonly string[]> = { frames: ['FRAME', 'SLOT'], instances: ['INSTANCE'], components: ['COMPONENT'] };
|
|
27
|
+
|
|
28
|
+
/** What one token on one field comes to: what to write, or the problem that stops it. */
|
|
29
|
+
type Check = { problem: string } | { variable?: Variable; atDefaults?: { value: VariableValue | number[]; context: string } };
|
|
30
|
+
|
|
31
|
+
function fail(message: string, code: 'NO_RESOLVER' | 'INVALID_QUERY' = 'INVALID_QUERY'): void {
|
|
32
|
+
figma.ui.postMessage({ type: 'ERROR', payload: { message, code, commandType: 'PLAN_APPLY' } } satisfies Response);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function PLAN_APPLY(payload: Payload): Promise<void> {
|
|
36
|
+
const { resolver } = getState();
|
|
37
|
+
if (!resolver) return fail('Load a design system before planning apply: its tokens are what Apply writes.', 'NO_RESOLVER');
|
|
38
|
+
const path = payload.tokens.trim().replace(/^\{(.*)\}$/, '$1');
|
|
39
|
+
// The core at the declared defaults, as a build asks it: each token's type, and its value in Figma's representation.
|
|
40
|
+
const core = coreAtDefaults();
|
|
41
|
+
if (!core) return fail('The loaded design system has no declared defaults to read its tokens at.', 'NO_RESOLVER');
|
|
42
|
+
|
|
43
|
+
// The fields each layer kind gets, as a component file's `tokens=` would give them.
|
|
44
|
+
const found = designSourceOf(resolver).group!(path);
|
|
45
|
+
if (found === undefined) return fail(`"${path}" names no token and no group of the design system`);
|
|
46
|
+
let plan: ReturnType<typeof planOf> | undefined;
|
|
47
|
+
if (payload.field !== undefined) {
|
|
48
|
+
if (found !== 'token')
|
|
49
|
+
return fail(`"${path}" is a group: a field is picked for one token, and a group's tokens reach fields by their last keys`);
|
|
50
|
+
if (!Object.prototype.hasOwnProperty.call(FIELDS, payload.field) || !FIELDS[payload.field].token)
|
|
51
|
+
return fail(`"${payload.field}" is not a field a token reaches`);
|
|
52
|
+
} else if (found === 'token') {
|
|
53
|
+
// One token is a set of one: it reaches a field by its last key.
|
|
54
|
+
const key = path.slice(path.lastIndexOf('.') + 1);
|
|
55
|
+
const info: GroupInfo = { keys: [key], paths: { [key]: path }, ...(core.valueOf(path)?.type === 'color' ? { colors: [key] } : {}) };
|
|
56
|
+
plan = planOf(path, info);
|
|
57
|
+
} else {
|
|
58
|
+
plan = planOf(path, found);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Each token is checked against each field once, however many layers it goes on.
|
|
62
|
+
const variables = await figma.variables.getLocalVariablesAsync();
|
|
63
|
+
const checks = new Map<string, Check>();
|
|
64
|
+
const check = (at: string, field: string): Check => {
|
|
65
|
+
const key = `${at}\n${field}`;
|
|
66
|
+
let result = checks.get(key);
|
|
67
|
+
if (!result) checks.set(key, (result = checkToken(at, field)));
|
|
68
|
+
return result;
|
|
69
|
+
};
|
|
70
|
+
const checkToken = (at: string, field: string): Check => {
|
|
71
|
+
const spec = FIELDS[field].token!;
|
|
72
|
+
const token = core.valueOf(at);
|
|
73
|
+
const type = token?.type ?? '';
|
|
74
|
+
if (!token) return { problem: `token {${at}} is not in the design system` };
|
|
75
|
+
if (!spec.types.includes(type))
|
|
76
|
+
return { problem: `token {${at}} is a ${type || 'token with no type'}, and "${field}" takes ${spec.types.join(' or ')}` };
|
|
77
|
+
if (namesStyle(FIELDS[field])) return { problem: `"${field}" takes a style, which Apply does not write; a component file does` };
|
|
78
|
+
// A gradient, a shadow or a grid is written part by part, each part bound or written: a component file's work.
|
|
79
|
+
if (type === 'gradient' || FIELDS[field].value === 'effects' || FIELDS[field].value === 'grids') {
|
|
80
|
+
return { problem: `token {${at}} on "${field}" is written part by part, which Apply does not do; a component file does` };
|
|
81
|
+
}
|
|
82
|
+
const atDefaults = token.value === undefined ? undefined : { value: token.value, context: core.context };
|
|
83
|
+
if (!spec.bind)
|
|
84
|
+
return atDefaults
|
|
85
|
+
? { atDefaults }
|
|
86
|
+
: { problem: `token {${at}} on "${field}" is written as its value, and Figma cannot take its value` };
|
|
87
|
+
const name = tokenPathToVariableName(at);
|
|
88
|
+
const named = variables.filter((variable) => variable.name === name);
|
|
89
|
+
if (named.length === 0)
|
|
90
|
+
return { problem: `token {${at}} names no variable in this file: looked for "${name}"; run the variable sync first` };
|
|
91
|
+
if (named.length > 1) return { problem: `token {${at}} is ambiguous, ${named.length} variables in this file are named "${name}"` };
|
|
92
|
+
if (named[0].resolvedType !== spec.bind.variable)
|
|
93
|
+
return { problem: `token {${at}} is a ${named[0].resolvedType} variable, and "${field}" needs a ${spec.bind.variable} one` };
|
|
94
|
+
return { variable: named[0], atDefaults };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const kinds = new Set<string>();
|
|
98
|
+
for (const kind of payload.kinds) for (const type of TYPES[kind] ?? []) kinds.add(type);
|
|
99
|
+
const layers = (await new QueryEngine().resolve('**', payload.scope)).filter((node) => kinds.has(node.type));
|
|
100
|
+
|
|
101
|
+
const operations: Operation[] = [];
|
|
102
|
+
const said = new Set<string>();
|
|
103
|
+
const once = (row: Operation, key: string): void => {
|
|
104
|
+
if (!said.has(key)) operations.push(row);
|
|
105
|
+
said.add(key);
|
|
106
|
+
};
|
|
107
|
+
if (layers.length === 0)
|
|
108
|
+
operations.push({ kind: 'SKIP', reason: `no ${payload.kinds.join(', ') || 'layers'} in ${payload.scope}`, path });
|
|
109
|
+
for (const node of layers) {
|
|
110
|
+
const kind = applyKindOf(node);
|
|
111
|
+
const layout = ((node as Partial<FrameNode>).layoutMode ?? 'NONE') as 'NONE';
|
|
112
|
+
const laid: Laid[] = plan
|
|
113
|
+
? [plan.box, plan[layout]].filter((each): each is Laid => each !== undefined)
|
|
114
|
+
: [{ set: { [payload.field!]: path } }];
|
|
115
|
+
for (const each of laid) {
|
|
116
|
+
for (const note of each.notes ?? []) operations.push({ kind: 'SKIP', reason: note, path: node.name });
|
|
117
|
+
for (const warning of each.warnings ?? []) once({ kind: 'WARNING', message: warning, path }, warning);
|
|
118
|
+
for (const [field, at] of Object.entries(each.set)) {
|
|
119
|
+
if (!FIELDS[field].nodes.includes(kind)) {
|
|
120
|
+
operations.push({ kind: 'SKIP', reason: `a ${kind} has no field "${field}"`, path: `${node.name} · ${field}` });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const checked = check(at, field);
|
|
124
|
+
if ('problem' in checked) {
|
|
125
|
+
once({ kind: 'WARNING', message: checked.problem, path: at }, checked.problem);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const written = valueLine(node.name, resolvedFor(checked.variable, checked.atDefaults), applyLayer(node, field, at), field);
|
|
129
|
+
operations.push({
|
|
130
|
+
kind: 'APPLY_TOKEN',
|
|
131
|
+
nodeId: node.id,
|
|
132
|
+
nodeName: node.name,
|
|
133
|
+
field,
|
|
134
|
+
path: at,
|
|
135
|
+
...(checked.variable ? { variableId: checked.variable.id } : {}),
|
|
136
|
+
...(written && checked.atDefaults ? { value: checked.atDefaults.value, context: checked.atDefaults.context, written } : {}),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
setState({ lastPlan: operations, lastPlanType: 'apply' });
|
|
143
|
+
figma.ui.postMessage({
|
|
144
|
+
type: 'PLAN_READY',
|
|
145
|
+
payload: {
|
|
146
|
+
planType: 'apply',
|
|
147
|
+
operations,
|
|
148
|
+
summary: {
|
|
149
|
+
total: operations.length,
|
|
150
|
+
applies: operations.filter((op) => op.kind === 'APPLY_TOKEN').length,
|
|
151
|
+
written: operations.filter((op) => op.kind === 'APPLY_TOKEN' && op.written !== undefined).length,
|
|
152
|
+
skips: operations.filter((op) => op.kind === 'SKIP').length,
|
|
153
|
+
warnings: operations.filter((op) => op.kind === 'WARNING').length,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
} satisfies Response);
|
|
157
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Command, Response } from '../../shared/protocol.js';
|
|
2
|
+
import type { Operation } from '../../shared/operations.js';
|
|
3
|
+
import { PropertyMapper } from '../engines/propertyMapper.js';
|
|
4
|
+
import { QueryEngine } from '../engines/queryEngine.js';
|
|
5
|
+
import { getState, setState } from '../runtimeState.js';
|
|
6
|
+
import { danglingSymbolWarnings } from '../utils/planSymbols.js';
|
|
7
|
+
|
|
8
|
+
type PlanApplyBindingsCommand = Extract<Command, { type: 'PLAN_APPLY_BINDINGS' }>;
|
|
9
|
+
|
|
10
|
+
function postToUi(message: Response): void {
|
|
11
|
+
figma.ui.postMessage(message);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function summarizeOperations(operations: Operation[]): Record<string, number> {
|
|
15
|
+
return {
|
|
16
|
+
total: operations.length,
|
|
17
|
+
binds: operations.filter((op) => op.kind === 'BIND_VARIABLE').length,
|
|
18
|
+
skips: operations.filter((op) => op.kind === 'SKIP').length,
|
|
19
|
+
warnings: operations.filter((op) => op.kind === 'WARNING').length,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function nodeById(id: string): Promise<SceneNode[]> {
|
|
24
|
+
const node = await figma.getNodeByIdAsync(id);
|
|
25
|
+
return node && 'visible' in node ? [node as SceneNode] : [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function PLAN_APPLY_BINDINGS(payload: PlanApplyBindingsCommand['payload']): Promise<void> {
|
|
29
|
+
const state = getState();
|
|
30
|
+
if (state.conventions.length === 0) {
|
|
31
|
+
postToUi({
|
|
32
|
+
type: 'ERROR',
|
|
33
|
+
payload: {
|
|
34
|
+
message: 'Load conventions before planning apply bindings.',
|
|
35
|
+
code: 'INVALID_CONVENTIONS',
|
|
36
|
+
commandType: 'PLAN_APPLY_BINDINGS',
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const mapper = new PropertyMapper(state.conventions);
|
|
43
|
+
// One node, from the Selection pane's Bind; otherwise what the query matches in scope.
|
|
44
|
+
const nodes =
|
|
45
|
+
payload.nodeId !== undefined ? await nodeById(payload.nodeId) : await new QueryEngine().resolve(payload.query, payload.scope);
|
|
46
|
+
|
|
47
|
+
const operations: Operation[] = [];
|
|
48
|
+
if (nodes.length === 0) {
|
|
49
|
+
operations.push({ kind: 'SKIP', reason: 'no nodes matched query', path: payload.nodeId ?? payload.query });
|
|
50
|
+
} else {
|
|
51
|
+
for (const node of nodes) {
|
|
52
|
+
const planned = await mapper.planApplyBindings(node);
|
|
53
|
+
operations.push(
|
|
54
|
+
...(payload.property === undefined
|
|
55
|
+
? planned
|
|
56
|
+
: planned.filter((op) => op.kind === 'BIND_VARIABLE' && op.property === payload.property)),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (operations.length === 0) {
|
|
60
|
+
operations.push({ kind: 'SKIP', reason: 'no applicable unbound variables found', path: payload.query });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
operations.push(...danglingSymbolWarnings(operations));
|
|
65
|
+
|
|
66
|
+
setState({
|
|
67
|
+
lastPlan: operations,
|
|
68
|
+
lastPlanType: 'apply_bindings',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
postToUi({
|
|
72
|
+
type: 'PLAN_READY',
|
|
73
|
+
payload: {
|
|
74
|
+
planType: 'apply_bindings',
|
|
75
|
+
operations,
|
|
76
|
+
summary: summarizeOperations(operations),
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Command, Response } from '../../shared/protocol.js';
|
|
2
|
+
import type { Operation } from '../../shared/operations.js';
|
|
3
|
+
import { PropertyMapper } from '../engines/propertyMapper.js';
|
|
4
|
+
import { QueryEngine } from '../engines/queryEngine.js';
|
|
5
|
+
import { getState, setState } from '../runtimeState.js';
|
|
6
|
+
import { danglingSymbolWarnings } from '../utils/planSymbols.js';
|
|
7
|
+
|
|
8
|
+
type PlanScaffoldCommand = Extract<Command, { type: 'PLAN_SCAFFOLD' }>;
|
|
9
|
+
|
|
10
|
+
function postToUi(message: Response): void {
|
|
11
|
+
figma.ui.postMessage(message);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function summarizeOperations(operations: Operation[]): Record<string, number> {
|
|
15
|
+
return {
|
|
16
|
+
total: operations.length,
|
|
17
|
+
creates: operations.filter((op) => op.kind === 'CREATE_COLLECTION' || op.kind === 'CREATE_VARIABLE').length,
|
|
18
|
+
binds: operations.filter((op) => op.kind === 'BIND_VARIABLE').length,
|
|
19
|
+
skips: operations.filter((op) => op.kind === 'SKIP').length,
|
|
20
|
+
warnings: operations.filter((op) => op.kind === 'WARNING').length,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function resolveScaffoldCollection(operations: Operation[], preferredName: string): Promise<string> {
|
|
25
|
+
const collections = await figma.variables.getLocalVariableCollectionsAsync();
|
|
26
|
+
if (collections.length > 0) {
|
|
27
|
+
return collections[0].id;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const collectionName = preferredName.trim() || 'resolved';
|
|
31
|
+
const symbolicId = `$col:${collectionName}`;
|
|
32
|
+
operations.push({
|
|
33
|
+
kind: 'CREATE_COLLECTION',
|
|
34
|
+
symbolicId,
|
|
35
|
+
name: collectionName,
|
|
36
|
+
modes: ['default'],
|
|
37
|
+
});
|
|
38
|
+
return symbolicId;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function PLAN_SCAFFOLD(payload: PlanScaffoldCommand['payload']): Promise<void> {
|
|
42
|
+
const state = getState();
|
|
43
|
+
if (state.conventions.length === 0) {
|
|
44
|
+
postToUi({
|
|
45
|
+
type: 'ERROR',
|
|
46
|
+
payload: {
|
|
47
|
+
message: 'Load conventions before planning scaffold operations.',
|
|
48
|
+
code: 'INVALID_CONVENTIONS',
|
|
49
|
+
commandType: 'PLAN_SCAFFOLD',
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const queryEngine = new QueryEngine();
|
|
56
|
+
const mapper = new PropertyMapper(state.conventions);
|
|
57
|
+
const nodes = await queryEngine.resolve(payload.query, payload.scope);
|
|
58
|
+
|
|
59
|
+
const operations: Operation[] = [];
|
|
60
|
+
if (nodes.length === 0) {
|
|
61
|
+
operations.push({ kind: 'SKIP', reason: 'no nodes matched query', path: payload.query });
|
|
62
|
+
} else {
|
|
63
|
+
const collectionId = await resolveScaffoldCollection(operations, state.resolver?.name ?? 'resolved');
|
|
64
|
+
for (const node of nodes) {
|
|
65
|
+
operations.push(...(await mapper.planScaffold(node, collectionId)));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
operations.push(...danglingSymbolWarnings(operations));
|
|
70
|
+
|
|
71
|
+
setState({
|
|
72
|
+
lastPlan: operations,
|
|
73
|
+
lastPlanType: 'scaffold',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
postToUi({
|
|
77
|
+
type: 'PLAN_READY',
|
|
78
|
+
payload: {
|
|
79
|
+
planType: 'scaffold',
|
|
80
|
+
operations,
|
|
81
|
+
summary: summarizeOperations(operations),
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Command, Response } from '../../shared/protocol.js';
|
|
2
|
+
import { StyleSyncEngine } from '../engines/styleSyncEngine.js';
|
|
3
|
+
import { getState, setState } from '../runtimeState.js';
|
|
4
|
+
import { danglingSymbolWarnings } from '../utils/planSymbols.js';
|
|
5
|
+
import { figmaCollectionNameFor } from '../utils/resolverModes.js';
|
|
6
|
+
|
|
7
|
+
type PlanStyleSyncCommand = Extract<Command, { type: 'PLAN_STYLE_SYNC' }>;
|
|
8
|
+
|
|
9
|
+
function postToUi(response: Response): void {
|
|
10
|
+
figma.ui.postMessage(response);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function PLAN_STYLE_SYNC(payload: PlanStyleSyncCommand['payload']): Promise<void> {
|
|
14
|
+
const state = getState();
|
|
15
|
+
if (!state.resolver) {
|
|
16
|
+
postToUi({
|
|
17
|
+
type: 'ERROR',
|
|
18
|
+
payload: {
|
|
19
|
+
message: 'Load a resolver document before planning style sync.',
|
|
20
|
+
code: 'NO_RESOLVER',
|
|
21
|
+
commandType: 'PLAN_STYLE_SYNC',
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The same name the variable plan was given, so a member's variable is looked for where that plan put it.
|
|
28
|
+
const collectionName = figmaCollectionNameFor(state.resolver, payload.collectionName);
|
|
29
|
+
const engine = new StyleSyncEngine();
|
|
30
|
+
const { operations } = await engine.buildPlan(state.resolver, state.conversionMap, collectionName, {
|
|
31
|
+
overwriteDrift: payload.overwriteDrift === true,
|
|
32
|
+
});
|
|
33
|
+
operations.push(...danglingSymbolWarnings(operations));
|
|
34
|
+
const summary = engine.summarizePlan(operations);
|
|
35
|
+
|
|
36
|
+
setState({
|
|
37
|
+
lastPlan: operations,
|
|
38
|
+
lastPlanType: 'style_sync',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
postToUi({
|
|
42
|
+
type: 'PLAN_READY',
|
|
43
|
+
payload: {
|
|
44
|
+
planType: 'style_sync',
|
|
45
|
+
operations,
|
|
46
|
+
summary,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|