@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.
- 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 +1058 -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 +24147 -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 +548 -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,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one way a component file reaches Figma: its text and its file name.
|
|
3
|
+
*
|
|
4
|
+
* The command line sends them, and the Templates pane keeps them in the file and
|
|
5
|
+
* sends them again; both end here. The plugin reads the KDL, evaluates it with
|
|
6
|
+
* kdlet once per member, validates, and then snapshots, diffs and applies
|
|
7
|
+
* (`run.ts`). A problem with the file is a `ComponentRunError` whose reasons are
|
|
8
|
+
* `<file>:<line>:<column> <message>`, so the person is told where it is, on the
|
|
9
|
+
* screen or in the terminal.
|
|
10
|
+
*
|
|
11
|
+
* The axes a file reads from the tokens (`(modifier)`) and its `tokens=` groups are
|
|
12
|
+
* answered from the resolver document the plugin has loaded; a file that needs one
|
|
13
|
+
* with none loaded is refused, naming what it needs (`shared/components/axes.ts`).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Collection, Part, Resolver } from '@toghen/lib';
|
|
17
|
+
import { readBlueprint, readComponentFile } from '../../shared/components/index.js';
|
|
18
|
+
import type { Blueprint, ComponentFile, Desired, Problem, RunOptions, Step } from '../../shared/components/index.js';
|
|
19
|
+
import type { CompactReport, StepSummary } from '../../shared/components/steps.js';
|
|
20
|
+
import { getState } from '../runtimeState.js';
|
|
21
|
+
import { designSourceOf } from '../utils/designSource.js';
|
|
22
|
+
import { defaultTokenTypeMap } from '../../shared/tokenTypeMap.js';
|
|
23
|
+
import { setUnfolded } from '../utils/folds.js';
|
|
24
|
+
import { figmaCollectionNameFor, resolveSumModes } from '../utils/resolverModes.js';
|
|
25
|
+
import { buildSum, type Sum } from '../utils/sum.js';
|
|
26
|
+
import { ComponentRunError } from './errors.js';
|
|
27
|
+
import { namedComponents, namedTokens } from './resolve.js';
|
|
28
|
+
import { run } from './run.js';
|
|
29
|
+
|
|
30
|
+
// kdlet reads a record's fields with Object.hasOwn (ES2022), and the plugin sandbox is built for ES2017: make sure it is there.
|
|
31
|
+
const sandbox = Object as { hasOwn?: unknown };
|
|
32
|
+
if (!sandbox.hasOwn) {
|
|
33
|
+
Object.defineProperty(Object, 'hasOwn', {
|
|
34
|
+
value: (object: object, key: PropertyKey) => Object.prototype.hasOwnProperty.call(object, key),
|
|
35
|
+
configurable: true,
|
|
36
|
+
writable: true,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `<file>:<line>:<column> <message>`, the shape editors and terminals make a link of. */
|
|
41
|
+
export function describe(fileName: string, problem: Problem): string {
|
|
42
|
+
const place = problem.line !== undefined ? `${fileName}:${problem.line}:${problem.column ?? 1}` : fileName;
|
|
43
|
+
return `${place} ${problem.severity === 'warning' ? 'warning: ' : ''}${problem.member !== undefined ? `[${problem.member}] ` : ''}${problem.message}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Reads a file against the loaded design system, when there is one. An artboard file (an `artboard` node at the top) is
|
|
48
|
+
* read against `inputs`, the data the command line resolved; a component file takes none.
|
|
49
|
+
*/
|
|
50
|
+
export function readComponentText(
|
|
51
|
+
text: string,
|
|
52
|
+
inputs?: Record<string, unknown>,
|
|
53
|
+
resolver: Resolver | null = getState().resolver,
|
|
54
|
+
): ComponentFile {
|
|
55
|
+
return readComponentFile(text, resolver ? designSourceOf(resolver) : undefined, inputs);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Reads a blueprint as {@link readComponentText} reads a file: each body against the loaded design system and `inputs`. */
|
|
59
|
+
export function readBlueprintText(
|
|
60
|
+
text: string,
|
|
61
|
+
inputs?: Record<string, unknown>,
|
|
62
|
+
resolver: Resolver | null = getState().resolver,
|
|
63
|
+
): Blueprint {
|
|
64
|
+
return readBlueprint(text, resolver ? designSourceOf(resolver) : undefined, inputs);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A file named for one kind that holds the other, which is a mistake the name makes plain. */
|
|
68
|
+
function wrongKind(read: ComponentFile, fileName: string): string | undefined {
|
|
69
|
+
if (read.artboard && /\.component\.kdl$/.test(fileName)) return `${fileName}: named as a component file, but it holds an artboard`;
|
|
70
|
+
if (!read.artboard && read.component && /\.artboard\.kdl$/.test(fileName))
|
|
71
|
+
return `${fileName}: named as an artboard file, but it holds a component`;
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What stops a file from being used: every error, with its place, or why there is no component at all. */
|
|
76
|
+
export function readErrors(read: ComponentFile, fileName: string): string[] {
|
|
77
|
+
const errors = read.problems.filter((problem) => problem.severity === 'error');
|
|
78
|
+
const kind = wrongKind(read, fileName);
|
|
79
|
+
if (kind) return [kind, ...errors.map((problem) => describe(fileName, problem))];
|
|
80
|
+
if (read.component && read.document && errors.length === 0) return [];
|
|
81
|
+
return errors.length > 0
|
|
82
|
+
? errors.map((problem) => describe(fileName, problem))
|
|
83
|
+
: [`${fileName}: ${read.artboard ? 'no artboard node' : 'no component node'}`];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What the report says of a step: what it did and to what, without the layer it carried. */
|
|
87
|
+
export function summarize(step: Step): StepSummary {
|
|
88
|
+
const summary: StepSummary = { kind: step.kind };
|
|
89
|
+
if ('member' in step) summary.member = typeof step.member === 'string' ? step.member : step.member.name;
|
|
90
|
+
if (step.kind === 'DELETE_MEMBER') summary.member = step.name;
|
|
91
|
+
if ('layer' in step) summary.address = step.layer.address;
|
|
92
|
+
if ('address' in step) summary.address = step.address;
|
|
93
|
+
if ('property' in step) summary.property = step.property.name;
|
|
94
|
+
if (step.kind === 'DELETE_PROPERTY') summary.property = step.name;
|
|
95
|
+
if ('changes' in step) summary.changes = step.changes;
|
|
96
|
+
// A patch names the fields it writes, and the ones whose stamp it only takes again.
|
|
97
|
+
if (step.kind === 'PATCH_LAYER') summary.changes = [...step.fields, ...(step.restamp ?? []).map((field) => `${field} (restamp)`)];
|
|
98
|
+
if (step.kind === 'RENAME_AXIS') {
|
|
99
|
+
summary.from = step.from;
|
|
100
|
+
summary.to = step.to;
|
|
101
|
+
summary.message = `axis "${step.from}" is renamed "${step.to}": every member keeps its node, and instances keep their value`;
|
|
102
|
+
}
|
|
103
|
+
if (step.kind === 'SWAP_COMPONENT') {
|
|
104
|
+
summary.from = step.from;
|
|
105
|
+
summary.to = step.to;
|
|
106
|
+
const where = step.component === step.member ? `"${step.member}"` : `"${step.component}/${step.member}"`;
|
|
107
|
+
const from =
|
|
108
|
+
step.from === ''
|
|
109
|
+
? 'a component no longer in the file'
|
|
110
|
+
: `"${step.from}" ${step.fromLibrary === undefined ? '(this file)' : step.fromLibrary === '' ? '(a library the lock does not name)' : `from library "${step.fromLibrary}"`}`;
|
|
111
|
+
const to = `"${step.to}" ${step.library === undefined ? '(this file)' : `from library "${step.library}"`}`;
|
|
112
|
+
summary.message = `"${step.address}" in ${where} swaps from ${from} to ${to}`;
|
|
113
|
+
}
|
|
114
|
+
if (step.kind === 'CREATE_SET') summary.members = step.members.length;
|
|
115
|
+
if ('message' in step) summary.message = step.message;
|
|
116
|
+
if ('breaks' in step && step.breaks) summary.breaks = step.breaks;
|
|
117
|
+
return summary;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** What a file that read asks the engine for: its component, its members, and whether it is an artboard. */
|
|
121
|
+
export function desiredOf(read: ComponentFile): Desired {
|
|
122
|
+
const { name, description, properties, across } = read.component!;
|
|
123
|
+
return {
|
|
124
|
+
component: { name, ...(description !== undefined ? { description } : {}), properties, ...(across !== undefined ? { across } : {}) },
|
|
125
|
+
members: read.members,
|
|
126
|
+
...(read.artboard ? { artboard: true as const } : {}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* What each file defines and the components it names by name, for the order a run's files go in
|
|
132
|
+
* (`shared/components/order.ts`). A file that does not read defines and names nothing: the check says why.
|
|
133
|
+
*/
|
|
134
|
+
export function namesOfFiles<T extends { fileName: string; text: string }>(
|
|
135
|
+
files: readonly T[],
|
|
136
|
+
inputs: Record<string, unknown> | undefined,
|
|
137
|
+
resolver: Resolver | null,
|
|
138
|
+
): (T & { defines?: string; names: string[] })[] {
|
|
139
|
+
return files.map((file) => {
|
|
140
|
+
const read = readComponentText(file.text, inputs, resolver);
|
|
141
|
+
if (!read.component || readErrors(read, file.fileName).length > 0) return { ...file, names: [] };
|
|
142
|
+
return { ...file, ...(read.artboard ? {} : { defines: read.component.name }), names: namedComponents(desiredOf(read)) };
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Reads, evaluates and validates one component file, then makes Figma agree with it. */
|
|
147
|
+
export async function runComponentFile(
|
|
148
|
+
text: string,
|
|
149
|
+
fileName: string,
|
|
150
|
+
options: RunOptions = {},
|
|
151
|
+
inputs?: Record<string, unknown>,
|
|
152
|
+
): Promise<{ component: string; report: CompactReport }> {
|
|
153
|
+
const read = readComponentText(text, inputs);
|
|
154
|
+
const errors = readErrors(read, fileName);
|
|
155
|
+
if (errors.length > 0 || !read.component) {
|
|
156
|
+
throw new ComponentRunError(
|
|
157
|
+
`${fileName} does not read: ${errors.length === 1 ? 'one problem' : `${errors.length} problems`}, and nothing was written`,
|
|
158
|
+
errors,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const { component } = read;
|
|
162
|
+
const report = await run(desiredOf(read), options);
|
|
163
|
+
// What the file warned of, and what inferring bindings from `tokens=` skipped or could not bind.
|
|
164
|
+
report.notes.push(...(component.notes || []));
|
|
165
|
+
report.warnings.push(
|
|
166
|
+
...read.problems.filter((problem) => problem.severity === 'warning').map((problem) => describe(fileName, problem)),
|
|
167
|
+
...(component.warnings || []),
|
|
168
|
+
);
|
|
169
|
+
return { component: component.name, report: { ...report, steps: report.steps.map(summarize) } };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Reads every file, and with `names` checks the names each writes against the design system loaded. Returns every
|
|
174
|
+
* problem of every file: nothing should be written while any file is wrong, since a design half applied is worse than none.
|
|
175
|
+
* The names are the design system's definition, not Figma's: a variable a sync is about to make is a name that is defined.
|
|
176
|
+
*
|
|
177
|
+
* The groups the files lay out as one variable per part (`layout=parts`) are told to the loaded document, so the variable
|
|
178
|
+
* sync that follows puts those components' variables where the files look for them.
|
|
179
|
+
*/
|
|
180
|
+
export function checkComponentFiles(
|
|
181
|
+
files: { fileName: string; text: string }[],
|
|
182
|
+
options: { names: boolean; inputs?: Record<string, unknown> },
|
|
183
|
+
resolver: Resolver | null = getState().resolver,
|
|
184
|
+
): string[] {
|
|
185
|
+
return checkComponents(files, options, resolver).errors;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* {@link checkComponentFiles}, and the warnings of the files too, each as `<file>:<line>:<column> warning: <message>`. The
|
|
190
|
+
* plugin's check says the errors; the command with no plugin (`toghen-figma check`) says both. Given the resolver, it reads
|
|
191
|
+
* nothing from the plugin's state.
|
|
192
|
+
*
|
|
193
|
+
* With `names`, a token the parts fold took into its rest's variable (`folds.ts`) is an error that names the rest and the
|
|
194
|
+
* mode to pin: it has no variable of its own. The sum is built once, after every file's `layout=parts` is known.
|
|
195
|
+
*/
|
|
196
|
+
export function checkComponents(
|
|
197
|
+
files: { fileName: string; text: string }[],
|
|
198
|
+
options: { names: boolean; inputs?: Record<string, unknown> },
|
|
199
|
+
resolver: Resolver | null,
|
|
200
|
+
): { errors: string[]; warnings: string[]; components: string[] } {
|
|
201
|
+
const problems: string[] = [];
|
|
202
|
+
const warnings: string[] = [];
|
|
203
|
+
const unfold: string[] = [];
|
|
204
|
+
const components: string[] = [];
|
|
205
|
+
// Every file first: the groups laid out as one variable per part decide which parts fold, and so which names are variables.
|
|
206
|
+
const reads: { fileName: string; read: ComponentFile }[] = [];
|
|
207
|
+
for (const { fileName, text } of files) {
|
|
208
|
+
const read = readComponentText(text, options.inputs, resolver);
|
|
209
|
+
const errors = readErrors(read, fileName);
|
|
210
|
+
problems.push(...errors);
|
|
211
|
+
warnings.push(...read.problems.filter((problem) => problem.severity === 'warning').map((problem) => describe(fileName, problem)));
|
|
212
|
+
if (read.component?.layout === 'parts' && read.component.tokens !== undefined) unfold.push(read.component.tokens);
|
|
213
|
+
if (errors.length === 0 && read.component && !read.artboard) components.push(read.component.name);
|
|
214
|
+
if (errors.length === 0) reads.push({ fileName, read });
|
|
215
|
+
}
|
|
216
|
+
if (resolver) setUnfolded(resolver, unfold);
|
|
217
|
+
if (!options.names || !resolver) return { errors: problems, warnings, components };
|
|
218
|
+
|
|
219
|
+
const { ds, dsCollectionName } = resolveSumModes(resolver);
|
|
220
|
+
const tokens = ds.getCollection(dsCollectionName) as Collection;
|
|
221
|
+
// The variables the sync makes, as it makes them. A document the sum cannot be built from is the variable sync's to say.
|
|
222
|
+
let sum: Sum | undefined;
|
|
223
|
+
try {
|
|
224
|
+
sum = buildSum(resolver, figmaCollectionNameFor(resolver), defaultTokenTypeMap);
|
|
225
|
+
} catch {
|
|
226
|
+
sum = undefined;
|
|
227
|
+
}
|
|
228
|
+
// A token the fold took into its rest's variable has none of its own: the part of the axis that folded, if it is one.
|
|
229
|
+
const foldedPart = (path: string): Part | undefined => {
|
|
230
|
+
if (!sum || sum.tokenByPath.has(`${sum.dsName}.${path}`)) return undefined;
|
|
231
|
+
const { parts, component } = tokens.partsOf(path);
|
|
232
|
+
const axes = sum.layouts.find((layout) => layout.group === component)?.axes ?? [];
|
|
233
|
+
return parts.find((part) => axes.some((axis) => axis.axis === part.axis && axis.folded));
|
|
234
|
+
};
|
|
235
|
+
for (const { fileName, read } of reads) {
|
|
236
|
+
const named = namedTokens(read.members);
|
|
237
|
+
for (const [what, names] of [
|
|
238
|
+
['token', named.floats],
|
|
239
|
+
['token', named.colors],
|
|
240
|
+
['token', named.effects],
|
|
241
|
+
['token', named.others],
|
|
242
|
+
['text style', named.textStyles],
|
|
243
|
+
['effect style', named.effectStyles],
|
|
244
|
+
] as const) {
|
|
245
|
+
for (const [path, where] of names) {
|
|
246
|
+
const place = where.loc ? `${fileName}:${where.loc.line}:${where.loc.column}` : fileName;
|
|
247
|
+
const via = where.via ? ` (${where.via})` : '';
|
|
248
|
+
// A token in any mode: the core says what a name is.
|
|
249
|
+
if (tokens.getNodeKind(path) !== 'token') {
|
|
250
|
+
problems.push(`${place} ${what} {${path}} is not in the design system "${dsCollectionName}"${via}`);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const part = foldedPart(path);
|
|
254
|
+
if (part) {
|
|
255
|
+
problems.push(
|
|
256
|
+
`${place} ${what} {${path}}${via} is the "${part.key}" part of "${part.twin}", which is one variable with a mode per "${part.axis}": name {${part.twin}}; the member pins the mode (a tokens= axis, or modes ${part.axis}=${part.key})`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return { errors: problems, warnings, components };
|
|
263
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A layer's receipt: for each field Toghen owns on it, a hash of what Figma reports for it.
|
|
3
|
+
*
|
|
4
|
+
* Two readings in one shape. `reportedOf` reads a node: what Figma holds now, which right after a write is the
|
|
5
|
+
* receipt and on a later run is compared with it (a hand edit shows as a difference). `expectedOf` reads the file:
|
|
6
|
+
* what Figma would report once the file's value is written, compared with the receipt to tell whether the file moved.
|
|
7
|
+
* Each field reads as the same plain data from both sides, so the two compare exactly: a bound field as its variable,
|
|
8
|
+
* a wired field as its property's key, a number as float32, since that is how Figma keeps it.
|
|
9
|
+
*
|
|
10
|
+
* Specs, *Identity: what is ours*, says how the two comparisons decide what a run writes.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { fieldNames } from '../../shared/components/diff.js';
|
|
14
|
+
import { FIELDS } from '../../shared/components/fields.js';
|
|
15
|
+
import type {
|
|
16
|
+
ComponentRef,
|
|
17
|
+
Constraints,
|
|
18
|
+
FieldValue,
|
|
19
|
+
GridPlacement,
|
|
20
|
+
Layer,
|
|
21
|
+
LayoutGridRecord,
|
|
22
|
+
SetField,
|
|
23
|
+
Track,
|
|
24
|
+
Value,
|
|
25
|
+
} from '../../shared/components/model.js';
|
|
26
|
+
import { BOUND_BY_PARTS, boundIdOf } from '../utils/boundVariables.js';
|
|
27
|
+
import {
|
|
28
|
+
effectsOf,
|
|
29
|
+
expectedText,
|
|
30
|
+
f32,
|
|
31
|
+
gradientPaint,
|
|
32
|
+
layoutGridOf,
|
|
33
|
+
literalBlur,
|
|
34
|
+
reportedEffect,
|
|
35
|
+
reportedGrid,
|
|
36
|
+
reportField,
|
|
37
|
+
reportedPaint,
|
|
38
|
+
withAlias,
|
|
39
|
+
} from './fieldWriter.js';
|
|
40
|
+
import type { Resolved } from './resolve.js';
|
|
41
|
+
import { receiptOf, stampOn, updateStamp } from './stamp.js';
|
|
42
|
+
|
|
43
|
+
/** A layer Toghen wrote in this run, and the fields it wrote: every field it declares when `fields` is absent. */
|
|
44
|
+
export interface Written {
|
|
45
|
+
node: SceneNode;
|
|
46
|
+
layer: Layer;
|
|
47
|
+
fields?: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A component as both sides name it: a variant by its set, since a `set` on an artboard's instance picks the variant. */
|
|
51
|
+
const componentId = (component: ComponentNode | null): string | null =>
|
|
52
|
+
component === null ? null : component.parent?.type === 'COMPONENT_SET' ? component.parent.id : component.id;
|
|
53
|
+
|
|
54
|
+
const nameOf = (key: string): string => key.split('#')[0];
|
|
55
|
+
|
|
56
|
+
/** What Figma reports now for each of these fields of a node, by the names `fieldNames` gives them. */
|
|
57
|
+
export async function reportedOf(node: SceneNode, fields: string[]): Promise<Record<string, unknown>> {
|
|
58
|
+
const read = node as unknown as Record<string, unknown>;
|
|
59
|
+
const out: Record<string, unknown> = {};
|
|
60
|
+
const bound = (node as unknown as { boundVariables?: Record<string, unknown> }).boundVariables ?? {};
|
|
61
|
+
for (const field of fields) {
|
|
62
|
+
if (field.startsWith('set ')) {
|
|
63
|
+
const properties = (node as InstanceNode).componentProperties;
|
|
64
|
+
const key = Object.keys(properties).find((candidate) => nameOf(candidate) === field.slice(4));
|
|
65
|
+
// A property bound to a variable is reported on the instance's `boundVariables.componentProperties` (roster, *Instance and component*).
|
|
66
|
+
const variable = key === undefined ? undefined : (bound.componentProperties as Record<string, VariableAlias> | undefined)?.[key]?.id;
|
|
67
|
+
out[field] = key === undefined ? null : variable !== undefined ? { variable } : properties[key].value;
|
|
68
|
+
} else if (FIELDS[field]?.wires !== undefined) {
|
|
69
|
+
const wired = node.componentPropertyReferences?.[field === 'component' ? 'mainComponent' : (field as 'visible' | 'characters')];
|
|
70
|
+
const variable = field === 'component' ? undefined : boundIdOf(bound, field);
|
|
71
|
+
if (field === 'component')
|
|
72
|
+
out[field] = wired !== undefined ? { property: wired } : componentId(await (node as InstanceNode).getMainComponentAsync());
|
|
73
|
+
else out[field] = wired !== undefined ? { property: wired, shows: read[field] } : variable !== undefined ? { variable } : read[field];
|
|
74
|
+
} else if (field === 'modes') {
|
|
75
|
+
out[field] = node.explicitVariableModes;
|
|
76
|
+
} else if (field === 'grid') {
|
|
77
|
+
const cell = node as unknown as GridChildrenMixin;
|
|
78
|
+
out[field] = {
|
|
79
|
+
row: cell.gridRowAnchorIndex,
|
|
80
|
+
column: cell.gridColumnAnchorIndex,
|
|
81
|
+
rowSpan: cell.gridRowSpan,
|
|
82
|
+
columnSpan: cell.gridColumnSpan,
|
|
83
|
+
};
|
|
84
|
+
} else if (FIELDS[field]?.value === 'tracks') {
|
|
85
|
+
out[field] = (read[field] as GridTrackSize[]).map((track) => ({
|
|
86
|
+
type: track.type,
|
|
87
|
+
value: track.type === 'HUG' ? null : f32(track.value),
|
|
88
|
+
}));
|
|
89
|
+
} else {
|
|
90
|
+
out[field] = reportField(node, field);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What a wired field reads from the set, by property name: the key the set holds the property under now, and its default, which the field shows. */
|
|
97
|
+
export interface Wiring {
|
|
98
|
+
keys: Map<string, string>;
|
|
99
|
+
defaults: Map<string, string | boolean>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function expectedPaint(resolved: Resolved, paint: Value): unknown {
|
|
103
|
+
// Unit 4: a gradient reads as the writer would leave it.
|
|
104
|
+
const composite = paint.kind === 'token' ? resolved.composite(paint.path) : undefined;
|
|
105
|
+
if (composite?.paint) return reportedPaint(gradientPaint(resolved, composite));
|
|
106
|
+
// A bound paint by its variable only: its alpha is the variable's, in whatever mode the layer is in (ruling 8a).
|
|
107
|
+
if (paint.kind === 'token') return { variable: resolved.color(paint.path).id };
|
|
108
|
+
const hex = String((paint as { value: string }).value);
|
|
109
|
+
const channel = (at: number): number => parseInt(hex.slice(at, at + 2), 16) / 255;
|
|
110
|
+
return { color: [channel(1), channel(3), channel(5)].map(f32), opacity: f32(hex.length === 9 ? channel(7) : 1) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function expectedField(resolved: Resolved, wiring: Wiring, field: string, value: FieldValue): unknown {
|
|
114
|
+
const scalar = value as Value;
|
|
115
|
+
if (FIELDS[field]?.token?.bind?.call === 'paint') return (value as Value[]).map((paint) => expectedPaint(resolved, paint));
|
|
116
|
+
if (FIELDS[field]?.token?.bind?.call === 'text') return expectedText(resolved, field, scalar);
|
|
117
|
+
// Unit 4: effects and layout grids read as the writer would leave them, each bound part by its variable.
|
|
118
|
+
if (FIELDS[field]?.value === 'effects') {
|
|
119
|
+
const effects: Effect[] = [];
|
|
120
|
+
for (const effect of value as Value[]) {
|
|
121
|
+
if (effect.kind === 'token') effects.push(...effectsOf(resolved, resolved.composite(effect.path)!, withAlias));
|
|
122
|
+
else effects.push(literalBlur(Number((effect as { value: number }).value)));
|
|
123
|
+
}
|
|
124
|
+
return effects.map(reportedEffect);
|
|
125
|
+
}
|
|
126
|
+
if (FIELDS[field]?.value === 'grids')
|
|
127
|
+
return (value as LayoutGridRecord[]).map((record) => reportedGrid(layoutGridOf(resolved, record, withAlias, () => 0)));
|
|
128
|
+
if (field === 'textStyle') return resolved.textStyle((value as { path: string }).path).id;
|
|
129
|
+
if (field === 'effectStyle') return resolved.effectStyle((value as { path: string }).path).id;
|
|
130
|
+
if (field === 'modes') {
|
|
131
|
+
const pins: Record<string, string> = {};
|
|
132
|
+
for (const [collection, mode] of Object.entries(value as Record<string, string>)) {
|
|
133
|
+
const pin = resolved.mode(collection, mode);
|
|
134
|
+
pins[pin.collection.id] = pin.modeId;
|
|
135
|
+
}
|
|
136
|
+
return pins;
|
|
137
|
+
}
|
|
138
|
+
if (field === 'grid') {
|
|
139
|
+
const cell = value as GridPlacement;
|
|
140
|
+
return { row: cell.row, column: cell.column, rowSpan: cell.rowSpan ?? 1, columnSpan: cell.columnSpan ?? 1 };
|
|
141
|
+
}
|
|
142
|
+
if (FIELDS[field]?.value === 'tracks')
|
|
143
|
+
return (value as Track[]).map((track) => ({ type: track.type, value: track.type === 'HUG' ? null : f32(track.value) }));
|
|
144
|
+
// A dash pattern as its lengths, a token's at the defaults; constraints as Figma reports them.
|
|
145
|
+
if (FIELDS[field]?.value === 'dashes') {
|
|
146
|
+
const dashes = value as Value[];
|
|
147
|
+
if (dashes[0]?.kind === 'token') return (resolved.valueOf(dashes[0].path)!.value as number[]).map(f32);
|
|
148
|
+
return dashes.map((dash) => f32((dash as { value: number }).value));
|
|
149
|
+
}
|
|
150
|
+
if (FIELDS[field]?.value === 'constraints') return { ...(value as Constraints) };
|
|
151
|
+
if (scalar.kind === 'property') {
|
|
152
|
+
const property = wiring.keys.get(scalar.name) ?? null;
|
|
153
|
+
const shows = wiring.defaults.get(scalar.name);
|
|
154
|
+
return field === 'component' ? { property } : { property, shows: field === 'visible' ? Boolean(shows) : String(shows) };
|
|
155
|
+
}
|
|
156
|
+
if (field === 'component')
|
|
157
|
+
return componentId(
|
|
158
|
+
resolved.component(scalar.kind === 'key' ? { key: scalar.key } : { name: String((scalar as { value: string }).value) }),
|
|
159
|
+
);
|
|
160
|
+
if (scalar.kind === 'token')
|
|
161
|
+
return FIELDS[field]?.token?.bind ? { variable: resolved.variable(scalar.path).id } : f32(resolved.valueOf(scalar.path)?.value);
|
|
162
|
+
return f32((scalar as { value: unknown }).value);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** An instance property's value as `componentProperties` reports it: a swap by its component's id. */
|
|
166
|
+
function expectedSet(resolved: Resolved, value: SetField[string]['value']): unknown {
|
|
167
|
+
if (typeof value === 'object')
|
|
168
|
+
return 'token' in value ? { variable: resolved.variable(value.token).id } : resolved.component({ key: value.key }).id;
|
|
169
|
+
if (typeof value !== 'string') return value;
|
|
170
|
+
// ponytail: a text that is also the name of a component the file swaps in reads as that component, and is written
|
|
171
|
+
// again on every run; the property's type, which would say, is only known from the instance.
|
|
172
|
+
try {
|
|
173
|
+
return resolved.component({ name: value } as ComponentRef).id;
|
|
174
|
+
} catch (_notAComponent) {
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The receipt the file's value would leave, for each field of a layer. A field whose lookup fails (a name a dry run
|
|
181
|
+
* only expects) has none, and reads as moved.
|
|
182
|
+
*/
|
|
183
|
+
export function expectedOf(layer: Layer, resolved: Resolved, wiring: Wiring): Record<string, string> {
|
|
184
|
+
const expected: Record<string, unknown> = {};
|
|
185
|
+
for (const [field, value] of Object.entries(layer.fields)) {
|
|
186
|
+
try {
|
|
187
|
+
if (field === 'set')
|
|
188
|
+
for (const [name, entry] of Object.entries(value as SetField)) expected[`set ${name}`] = expectedSet(resolved, entry.value);
|
|
189
|
+
else expected[field] = expectedField(resolved, wiring, field, value);
|
|
190
|
+
} catch (_unresolved) {
|
|
191
|
+
// Left out: the field is written.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// A field bound by its parts (`cornerRadius`, `strokeWeight`) beside a part of its own the file names: the target
|
|
195
|
+
// reports the field bound only when all four parts end on one variable, else `{ variable: null }` (`boundIdOf`), so
|
|
196
|
+
// the file's side reads the same; otherwise every run took the file for moved and wrote the field again.
|
|
197
|
+
for (const [field, parts] of Object.entries(BOUND_BY_PARTS)) {
|
|
198
|
+
if (!(field in expected) || !parts.some((part) => part in layer.fields)) continue;
|
|
199
|
+
try {
|
|
200
|
+
const ids = parts.map((part) => {
|
|
201
|
+
const value = (layer.fields[part] ?? layer.fields[field]) as Value;
|
|
202
|
+
return value.kind === 'token' && FIELDS[part]?.token?.bind ? resolved.variable(value.path).id : undefined;
|
|
203
|
+
});
|
|
204
|
+
if (ids.some((id) => id !== undefined)) expected[field] = { variable: new Set(ids).size === 1 ? ids[0] : null };
|
|
205
|
+
} catch (_unresolved) {
|
|
206
|
+
delete expected[field];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return receiptOf(expected);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Writes each layer's receipt once the run's writes are done: after a member is in its set, since combining gives its
|
|
214
|
+
* wired layers the set's keys. A field the run wrote takes the hash of what Figma reports now; one it did not keeps the
|
|
215
|
+
* hash it had, so a field kept as drift is still drift on the next run. The receipt lists the fields the file declares
|
|
216
|
+
* now: one it stopped declaring was reported once and leaves it.
|
|
217
|
+
*/
|
|
218
|
+
export async function stampReceipts(written: Written[]): Promise<void> {
|
|
219
|
+
for (const { node, layer, fields } of written) {
|
|
220
|
+
if (node.removed) continue;
|
|
221
|
+
const names = fieldNames(layer);
|
|
222
|
+
const now = receiptOf(await reportedOf(node, fields ?? names));
|
|
223
|
+
const held = stampOn(node)?.receipt ?? {};
|
|
224
|
+
const receipt: Record<string, string> = {};
|
|
225
|
+
for (const name of names) receipt[name] = now[name] ?? held[name] ?? '';
|
|
226
|
+
updateStamp(node, { receipt });
|
|
227
|
+
}
|
|
228
|
+
}
|