@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,1193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything a file names, turned into Figma objects, before anything is written.
|
|
3
|
+
*
|
|
4
|
+
* A reference is looked up by name and never falls back: a token that matches
|
|
5
|
+
* nothing stops the build, and so does one that matches in two collections. A
|
|
6
|
+
* component that silently stopped following its tokens is the defect this whole
|
|
7
|
+
* design exists to prevent, so the lookup either finds exactly one thing or
|
|
8
|
+
* reports why not. Every problem is collected and thrown together, so a run
|
|
9
|
+
* fixes a file in one pass.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { findInEveryPage } from '../utils/pageRoots.js';
|
|
13
|
+
import { viaAxes } from '../../shared/components/axes.js';
|
|
14
|
+
import { FIELDS, PIXELS_ONLY_FIELDS, SHADOW_FIELDS } from '../../shared/components/fields.js';
|
|
15
|
+
import { scopesOfField } from '../../shared/components/groups.js';
|
|
16
|
+
import type {
|
|
17
|
+
ComponentRef,
|
|
18
|
+
Layer,
|
|
19
|
+
LayoutGridRecord,
|
|
20
|
+
Loc,
|
|
21
|
+
Member,
|
|
22
|
+
PreferredValue,
|
|
23
|
+
Property,
|
|
24
|
+
SetField,
|
|
25
|
+
Value,
|
|
26
|
+
} from '../../shared/components/model.js';
|
|
27
|
+
import type { Desired, RunOptions } from '../../shared/components/steps.js';
|
|
28
|
+
import { getState } from '../runtimeState.js';
|
|
29
|
+
import { readToken, resolveSumModes, type MultiModeResolution } from '../utils/resolverModes.js';
|
|
30
|
+
import { fontStack, toFigmaValue } from '../utils/figmaTokenAdapter.js';
|
|
31
|
+
import { toEffectStyleProps, toFigmaLetterSpacing, toFigmaLineHeight, toPaintStyleProps } from '../utils/figmaStyleAdapter.js';
|
|
32
|
+
import { normalizeDimensionToPx, type DimensionValue } from '@toghen/lib';
|
|
33
|
+
import { buildSum, type Sum } from '../utils/sum.js';
|
|
34
|
+
import { keyIn, loadLock } from '../utils/lockStore.js';
|
|
35
|
+
import type { Lock, LockedLibrary, Overrides } from '../../shared/protocol.js';
|
|
36
|
+
import { styleTargetOf } from '../utils/styleTarget.js';
|
|
37
|
+
import { tokenPathToVariableName } from '../utils/tokenPath.js';
|
|
38
|
+
import { ComponentRunError } from './errors.js';
|
|
39
|
+
import { getStamp, isOwn } from './stamp.js';
|
|
40
|
+
|
|
41
|
+
export interface Resolved {
|
|
42
|
+
page: PageNode;
|
|
43
|
+
/** The variable a dimension or opacity token names. */
|
|
44
|
+
float(path: string): Variable;
|
|
45
|
+
/** The variable a colour token names. */
|
|
46
|
+
color(path: string): Variable;
|
|
47
|
+
textStyle(path: string): TextStyle;
|
|
48
|
+
/** The effect style style sync made from a shadow token. */
|
|
49
|
+
effectStyle(path: string): EffectStyle;
|
|
50
|
+
/** The component to make an instance of. A set resolves to its default variant. */
|
|
51
|
+
component(ref: ComponentRef): ComponentNode;
|
|
52
|
+
/** Where the locator found a component, imported or not: the name it answered with, the key it imports by, the declared library it came from. */
|
|
53
|
+
componentSource(ref: ComponentRef): { name: string; key?: string; library?: string };
|
|
54
|
+
/** The component or the set a preferred value by name names; undefined for one by key, and for a name a dry run did not import. */
|
|
55
|
+
preferred(value: PreferredValue): ComponentNode | ComponentSetNode | undefined;
|
|
56
|
+
mode(collection: string, mode: string): ModePin;
|
|
57
|
+
/** The text styles the members use, whose fonts must be loaded before any text is written. */
|
|
58
|
+
textStyles(): TextStyle[];
|
|
59
|
+
/** What a dry run counted as present because `options.pending` said it would be created, one line each. */
|
|
60
|
+
pending: string[];
|
|
61
|
+
/** What the run noticed and leaves as it is: a variable bound to a field it is not offered to, which still draws. */
|
|
62
|
+
notes: string[];
|
|
63
|
+
/**
|
|
64
|
+
* A token's value at the declared defaults, as the core answers it in Figma's representation, and the defaults' mode
|
|
65
|
+
* name: what a field, or part of one, that Figma cannot bind is written as. Undefined when the run did not need it.
|
|
66
|
+
* A `strokeStyle` gives its dash array, in pixels, for `dashPattern`.
|
|
67
|
+
*/
|
|
68
|
+
valueOf(path: string): { value: VariableValue | number[]; context: string } | undefined;
|
|
69
|
+
/** A gradient token on a paint, or a shadow or dimension token on `effects`, read from the core (unit 4). Undefined for any other. */
|
|
70
|
+
composite(path: string): Composite | undefined;
|
|
71
|
+
/** The variable a token names, of any type: what a text field, `visible`, `characters` or an instance property binds. */
|
|
72
|
+
variable(path: string): Variable;
|
|
73
|
+
/**
|
|
74
|
+
* A line height or letter spacing that is not in pixels, as Figma takes it (PERCENT, or AUTO), at the declared
|
|
75
|
+
* defaults, and the defaults' mode name: written as its value, since Figma binds these in pixels only. Undefined when
|
|
76
|
+
* the token is in pixels, and bound.
|
|
77
|
+
*/
|
|
78
|
+
textValue(field: string, path: string): { value: LineHeight | LetterSpacing; context: string } | undefined;
|
|
79
|
+
/** The families of a `fontFamily` token that is a stack of more than one, as the core gives it; its variable holds the first. */
|
|
80
|
+
stack(path: string): string[] | undefined;
|
|
81
|
+
/** What a `string` or `boolean` variable holds in its collection's default mode: what a layer wired to a property whose default it is shows. */
|
|
82
|
+
shows(path: string): string | boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What a layer's `modes` pins: a mode of a local collection, or of a collection a `--use` library holds. A library's
|
|
87
|
+
* is known by its library and collection names only, which FigCraft's `library.setVariableMode` takes; its `collection.id`
|
|
88
|
+
* is `library:<library>/<collection>` and its `modeId` the mode's name, so the record of what a run pinned compares it.
|
|
89
|
+
*/
|
|
90
|
+
export type ModePin =
|
|
91
|
+
| { collection: VariableCollection; modeId: string; library?: undefined }
|
|
92
|
+
/** `where` is the pin as the file wrote it, with its line, for the target's own error about the mode. */
|
|
93
|
+
| { library: { name: string; id?: string }; collection: { id: string; name: string }; modeId: string; where: string };
|
|
94
|
+
|
|
95
|
+
/** Whether the target pins a library collection's mode: FigCraft's does; Figma's waits for a measurement in the desktop app. */
|
|
96
|
+
let libraryModes = false;
|
|
97
|
+
export function pinLibraryModes(on: boolean): void {
|
|
98
|
+
libraryModes = on;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A token that becomes one Figma object or several, with parts bound one by one (unit 4): a gradient's paint, whose
|
|
103
|
+
* stops bind their colours; a shadow's effects, whose five fields bind; a dimension's layer blur, whose radius binds.
|
|
104
|
+
*/
|
|
105
|
+
export interface Composite {
|
|
106
|
+
/** A gradient's paint at the declared defaults, unbound. */
|
|
107
|
+
paint?: GradientPaint;
|
|
108
|
+
/** A shadow's effects, or a blur's one, at the declared defaults, unbound. */
|
|
109
|
+
effects?: Effect[];
|
|
110
|
+
/** The token each bound part follows, by part: `<index>.<field>`, the stop or effect and its field (`1.color`, `0.radius`). */
|
|
111
|
+
bound: Record<string, string>;
|
|
112
|
+
/** The parts written as values, in the file's words, for the report: `stop positions`, `spread`. */
|
|
113
|
+
written: string[];
|
|
114
|
+
/** The declared defaults' mode name, which the written parts follow. */
|
|
115
|
+
context: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A reference and where the file first wrote it, so a message can say `line 12`. */
|
|
119
|
+
export interface Wanted {
|
|
120
|
+
loc?: Loc;
|
|
121
|
+
/** The member the reference was first found in. */
|
|
122
|
+
member?: string;
|
|
123
|
+
/** The member and the axis values a reference was expanded from (`{hue.(input)hue.soft}`), when it was: said beside the line. */
|
|
124
|
+
via?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const at = (wanted: Wanted): string => {
|
|
128
|
+
const parts = [wanted.loc ? `line ${wanted.loc.line}` : '', wanted.via ?? ''].filter((part) => part !== '');
|
|
129
|
+
return parts.length > 0 ? ` (${parts.join(', ')})` : '';
|
|
130
|
+
};
|
|
131
|
+
const pathKey = (path: string): string => `{${path}}`;
|
|
132
|
+
|
|
133
|
+
function references(layer: Layer, into: References, member?: Member): void {
|
|
134
|
+
for (const [field, value] of Object.entries(layer.fields)) {
|
|
135
|
+
const spec = FIELDS[field];
|
|
136
|
+
const at = layer.fieldLoc?.[field];
|
|
137
|
+
if (!spec) continue;
|
|
138
|
+
// A reference expanded from an axis says which member and which axis values made it: the same path is not the same line in every member.
|
|
139
|
+
const axes = layer.fromAxes?.[field];
|
|
140
|
+
const loc: Wanted = {
|
|
141
|
+
...(at ? { loc: at } : {}),
|
|
142
|
+
...(member ? { member: member.name } : {}),
|
|
143
|
+
...(member && axes ? { via: `member "${member.name}", ${viaAxes(axes, member.combination)}` } : {}),
|
|
144
|
+
};
|
|
145
|
+
// The field a token is bound to, once per token and field: what the unit and the scopes of its variable are held against.
|
|
146
|
+
const bind = (path: string): void => {
|
|
147
|
+
const fields = into.bound.get(path) ?? into.bound.set(path, new Map()).get(path)!;
|
|
148
|
+
if (!fields.has(field)) fields.set(field, { text: layer.kind === 'TEXT', where: loc });
|
|
149
|
+
};
|
|
150
|
+
const given = value as Value;
|
|
151
|
+
if (PIXELS_ONLY_FIELDS.includes(field)) {
|
|
152
|
+
// A line height or letter spacing: bound in pixels, written as its value otherwise. Which, the token's unit says.
|
|
153
|
+
if (given.kind === 'token' && !into.metrics.has(`${field} ${given.path}`))
|
|
154
|
+
into.metrics.set(`${field} ${given.path}`, { ...loc, field, path: given.path });
|
|
155
|
+
} else if (spec.token?.bind && (spec.value === 'token' || spec.value === 'boolean' || spec.value === 'string')) {
|
|
156
|
+
// A font's halves, `visible` and `characters`: the variable type is the table's.
|
|
157
|
+
if (given.kind === 'token') {
|
|
158
|
+
addTyped(into, given.path, spec.token.bind.variable, loc);
|
|
159
|
+
bind(given.path);
|
|
160
|
+
}
|
|
161
|
+
} else if ((spec.value === 'dimension' || spec.value === 'size') && !spec.token?.bind) {
|
|
162
|
+
// A field Figma cannot bind: the token is written as its value, which the core gives.
|
|
163
|
+
const scalar = value as Value;
|
|
164
|
+
if (scalar.kind === 'token' && !into.values.has(scalar.path)) into.values.set(scalar.path, { ...loc, field });
|
|
165
|
+
} else if (spec.value === 'dimension' || spec.value === 'size') {
|
|
166
|
+
const scalar = value as Value;
|
|
167
|
+
if (scalar.kind === 'token') {
|
|
168
|
+
into.floats.set(scalar.path, into.floats.get(scalar.path) ?? loc);
|
|
169
|
+
bind(scalar.path);
|
|
170
|
+
}
|
|
171
|
+
} else if (spec.value === 'dashes') {
|
|
172
|
+
// A dash pattern Figma cannot bind: its strokeStyle token is written as its dash array, which the core gives.
|
|
173
|
+
for (const dash of value as Value[])
|
|
174
|
+
if (dash.kind === 'token' && !into.values.has(dash.path)) into.values.set(dash.path, { ...loc, field });
|
|
175
|
+
} else if (spec.value === 'paints') {
|
|
176
|
+
for (const paint of value as Value[]) {
|
|
177
|
+
if (paint.kind === 'token') {
|
|
178
|
+
into.colors.set(paint.path, into.colors.get(paint.path) ?? loc);
|
|
179
|
+
bind(paint.path);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} else if (spec.value === 'effects') {
|
|
183
|
+
// Unit 4: a shadow or a blur, which only the design system tells apart; `resolve` asks it.
|
|
184
|
+
for (const effect of value as Value[])
|
|
185
|
+
if (effect.kind === 'token') into.effects.set(effect.path, into.effects.get(effect.path) ?? loc);
|
|
186
|
+
} else if (spec.value === 'grids') {
|
|
187
|
+
// Unit 4: a grid's numbers are bound; its colour is written as its value.
|
|
188
|
+
for (const grid of value as LayoutGridRecord[]) {
|
|
189
|
+
for (const [key, part] of Object.entries(grid)) {
|
|
190
|
+
if (typeof part !== 'object' || part.kind !== 'token') continue;
|
|
191
|
+
if (key === 'color') {
|
|
192
|
+
if (!into.values.has(part.path)) into.values.set(part.path, { ...loc, field });
|
|
193
|
+
} else {
|
|
194
|
+
into.floats.set(part.path, into.floats.get(part.path) ?? loc);
|
|
195
|
+
bind(part.path);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} else if (spec.value === 'token') {
|
|
200
|
+
const scalar = value as Value;
|
|
201
|
+
const styles = field === 'effectStyle' ? into.effectStyles : into.textStyles;
|
|
202
|
+
if (scalar.kind === 'token') styles.set(scalar.path, styles.get(scalar.path) ?? loc);
|
|
203
|
+
} else if (spec.value === 'component') {
|
|
204
|
+
const scalar = value as Value;
|
|
205
|
+
if (scalar.kind === 'plain' && typeof scalar.value === 'string') addComponent(into, { name: scalar.value }, at);
|
|
206
|
+
else if (scalar.kind === 'key') addComponent(into, { key: scalar.key }, at);
|
|
207
|
+
} else if (spec.value === 'modes') {
|
|
208
|
+
for (const [collection, mode] of Object.entries(value as Record<string, string>)) {
|
|
209
|
+
const key = `${collection}=${mode}`;
|
|
210
|
+
into.modes.set(key, into.modes.get(key) ?? { loc: at });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
for (const child of layer.children) references(child, into, member);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The tokens and text styles the members name, each with where the file first wrote it: what a repository can check without Figma. */
|
|
218
|
+
export function namedTokens(
|
|
219
|
+
members: Member[],
|
|
220
|
+
): Pick<References, 'floats' | 'colors' | 'textStyles' | 'effectStyles' | 'effects'> & { others: Map<string, Wanted> } {
|
|
221
|
+
const into = emptyReferences();
|
|
222
|
+
for (const member of members) references(member.tree, into, member);
|
|
223
|
+
// The tokens of the text fields, `visible` and `characters`, once each.
|
|
224
|
+
const others = new Map<string, Wanted>();
|
|
225
|
+
for (const entry of [...into.typed.values(), ...into.metrics.values()]) if (!others.has(entry.path)) others.set(entry.path, entry);
|
|
226
|
+
return { ...into, others };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const emptyReferences = (): References => ({
|
|
230
|
+
floats: new Map(),
|
|
231
|
+
colors: new Map(),
|
|
232
|
+
textStyles: new Map(),
|
|
233
|
+
effectStyles: new Map(),
|
|
234
|
+
components: new Map(),
|
|
235
|
+
modes: new Map(),
|
|
236
|
+
bound: new Map(),
|
|
237
|
+
values: new Map(),
|
|
238
|
+
typed: new Map(),
|
|
239
|
+
metrics: new Map(),
|
|
240
|
+
effects: new Map(),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
/** A token whose variable of this type is bound, once per type: a path wanted as two types is refused by the second lookup. */
|
|
244
|
+
function addTyped(into: References, path: string, type: VariableResolvedDataType, where: Wanted): void {
|
|
245
|
+
const key = `${type} ${path}`;
|
|
246
|
+
if (!into.typed.has(key)) into.typed.set(key, { ...where, path, type });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The preferred values of an `INSTANCE_SWAP` or a `SLOT`; none for any other property. */
|
|
250
|
+
export const preferredOf = (property: Property): PreferredValue[] =>
|
|
251
|
+
property.type === 'INSTANCE_SWAP' || property.type === 'SLOT' ? (property.preferredValues ?? []) : [];
|
|
252
|
+
|
|
253
|
+
const componentKey = (ref: ComponentRef): string => ('name' in ref ? `name:${ref.name}` : `key:${ref.key}`);
|
|
254
|
+
|
|
255
|
+
function addComponent(into: References, ref: ComponentRef, loc?: Loc): void {
|
|
256
|
+
const key = componentKey(ref);
|
|
257
|
+
if (!into.components.has(key)) into.components.set(key, { ref, loc });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface References {
|
|
261
|
+
floats: Map<string, Wanted>;
|
|
262
|
+
colors: Map<string, Wanted>;
|
|
263
|
+
textStyles: Map<string, Wanted>;
|
|
264
|
+
effectStyles: Map<string, Wanted>;
|
|
265
|
+
components: Map<string, Wanted & { ref: ComponentRef }>;
|
|
266
|
+
modes: Map<string, Wanted>;
|
|
267
|
+
/** Each token the members bind, to each field it is bound to, and whether that layer is a text. */
|
|
268
|
+
bound: Map<string, Map<string, { text: boolean; where: Wanted }>>;
|
|
269
|
+
/** Each token on a field Figma cannot bind, with the first field it is on: written as its value. */
|
|
270
|
+
values: Map<string, Wanted & { field: string }>;
|
|
271
|
+
/** Each token bound as a `STRING`, `BOOLEAN` or (a font's weight) `FLOAT` variable, by type and path. */
|
|
272
|
+
typed: Map<string, Wanted & { path: string; type: VariableResolvedDataType }>;
|
|
273
|
+
/** Each token on a line height or letter spacing, by field and path: bound in pixels, written as its value otherwise. */
|
|
274
|
+
metrics: Map<string, Wanted & { field: string; path: string }>;
|
|
275
|
+
/** Each token on `effects`: a shadow or a blur, as the design system says (unit 4). */
|
|
276
|
+
effects: Map<string, Wanted>;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** What a failed import says. An unpublished key fails as an unknown one does (measured), so the error cannot tell which. */
|
|
280
|
+
export const NOT_IMPORTED = 'not published, or not this key: publish the library, then accept its update in this file';
|
|
281
|
+
|
|
282
|
+
/** The libraries a file declares, each read through the overrides map, as the locator reads them. */
|
|
283
|
+
export const declaredLibraries = (declared: readonly string[], overrides: Overrides = {}): string[] => [
|
|
284
|
+
...new Set(declared.map((name) => overrides[name] ?? name)),
|
|
285
|
+
];
|
|
286
|
+
|
|
287
|
+
/** The components and sets a file can make an instance of, on every page loaded: a member of a set is named for its combination, and is not one. */
|
|
288
|
+
export const localComponents = (): SceneNode[] =>
|
|
289
|
+
findInEveryPage({ types: ['COMPONENT', 'COMPONENT_SET'] }).filter(
|
|
290
|
+
(node) => !(node.type === 'COMPONENT' && node.parent?.type === 'COMPONENT_SET'),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
/** Where the locator finds a component a file names, and why it finds none or too many. */
|
|
294
|
+
export type Located =
|
|
295
|
+
/** The overrides map sends the name to a key. */
|
|
296
|
+
| { at: 'key'; name: string; key: string }
|
|
297
|
+
| { at: 'library'; name: string; library: string; key: string; set: boolean }
|
|
298
|
+
| { at: 'file'; name: string; node: SceneNode }
|
|
299
|
+
| { at: 'missing'; name: string }
|
|
300
|
+
/** In this file and in a declared library: it stops the build. */
|
|
301
|
+
| { at: 'both'; name: string; here: SceneNode[]; libraries: string[] }
|
|
302
|
+
| { at: 'several'; name: string; libraries: string[] }
|
|
303
|
+
| { at: 'ambiguous'; name: string; here: SceneNode[] };
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* A component's name, answered in the locator's one order, never falling back: the overrides map first, then a
|
|
307
|
+
* declared library's lock entry, then this file. `name` is the name it answered with. Reads only: nothing is imported.
|
|
308
|
+
*/
|
|
309
|
+
export function locateComponent(written: string, libraries: readonly string[], lock: Lock, local: readonly SceneNode[]): Located {
|
|
310
|
+
const name = lock.overrides?.[written] ?? written;
|
|
311
|
+
const key = keyIn(name);
|
|
312
|
+
if (key !== undefined) return { at: 'key', name, key };
|
|
313
|
+
const here = local.filter((node) => node.name === name);
|
|
314
|
+
const from = libraries.filter((library) => lock.libraries[library]?.components[name] !== undefined);
|
|
315
|
+
if (from.length > 0) {
|
|
316
|
+
if (here.length > 0) return { at: 'both', name, here, libraries: from };
|
|
317
|
+
if (from.length > 1) return { at: 'several', name, libraries: from };
|
|
318
|
+
const entry = lock.libraries[from[0]].components[name];
|
|
319
|
+
return { at: 'library', name, library: from[0], key: entry.key, set: entry.set };
|
|
320
|
+
}
|
|
321
|
+
if (here.length === 0) return { at: 'missing', name };
|
|
322
|
+
if (here.length > 1) return { at: 'ambiguous', name, here };
|
|
323
|
+
return { at: 'file', name, node: here[0] };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The collection and the name the variable sync gives a token, by its path: what the lock keeps a library's variables by. */
|
|
327
|
+
export function variableSlots(
|
|
328
|
+
sum = sumOfLoadedDocument(),
|
|
329
|
+
): ((path: string) => { collection: string; name: string } | undefined) | undefined {
|
|
330
|
+
return sum && ((path) => sum.tokenByPath.get(`${sum.dsName}.${path}`)?.variables[0]);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** What a file names, as `resolve` looks it up. */
|
|
334
|
+
function wantedOf(desired: Desired): References {
|
|
335
|
+
const wanted = emptyReferences();
|
|
336
|
+
for (const member of desired.members) references(member.tree, wanted, member);
|
|
337
|
+
for (const property of desired.component.properties) {
|
|
338
|
+
if (property.type === 'INSTANCE_SWAP') addComponent(wanted, property.default, property.loc);
|
|
339
|
+
// A preferred value by name is found as an instance swap's default is; one by key is the target's to take.
|
|
340
|
+
for (const value of preferredOf(property)) if ('name' in value) addComponent(wanted, { name: value.name }, value.loc ?? property.loc);
|
|
341
|
+
// A default that is a token binds its variable: a `boolean` one to a BOOLEAN property, a `string` one to a TEXT property.
|
|
342
|
+
if ((property.type === 'BOOLEAN' || property.type === 'TEXT') && typeof property.default === 'object') {
|
|
343
|
+
addTyped(
|
|
344
|
+
wanted,
|
|
345
|
+
property.default.token,
|
|
346
|
+
property.type === 'BOOLEAN' ? 'BOOLEAN' : 'STRING',
|
|
347
|
+
property.loc ? { loc: property.loc } : {},
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return wanted;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The components a file names by name, as `resolve` looks them up: an instance's `component`, an `INSTANCE_SWAP`
|
|
356
|
+
* default, a preferred value. What the order of a run's files is computed from (`shared/components/order.ts`).
|
|
357
|
+
*/
|
|
358
|
+
// ponytail: a component named only as an instance's `set` value of an INSTANCE_SWAP is left out, since which `set`
|
|
359
|
+
// values name components is known only from the built component; add it if a run is found misordered by one.
|
|
360
|
+
export const namedComponents = (desired: Desired): string[] =>
|
|
361
|
+
[...wantedOf(desired).components.values()]
|
|
362
|
+
.map(({ ref }) => ('name' in ref ? ref.name : undefined))
|
|
363
|
+
.filter((name): name is string => name !== undefined);
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Resolves what `desired` names.
|
|
367
|
+
*
|
|
368
|
+
* Reads the file's variables, styles, components and pages; writes nothing, with
|
|
369
|
+
* one exception the caller controls: what a published key names (a `(key)` in the
|
|
370
|
+
* file, the overrides map, or the lock for a declared library) has to be imported
|
|
371
|
+
* to be found, and a dry run does not import.
|
|
372
|
+
*
|
|
373
|
+
* Each name is answered in one order, and never falls back: the lock's overrides map
|
|
374
|
+
* replaces it first; a name a declared library's lock entry holds imports by key; any
|
|
375
|
+
* other resolves in this file. A name found both in this file and in a declared
|
|
376
|
+
* library stops the build. See `specs.md`, *The lock*.
|
|
377
|
+
*/
|
|
378
|
+
export async function resolve(desired: Desired, options: RunOptions): Promise<Resolved> {
|
|
379
|
+
const wanted = wantedOf(desired);
|
|
380
|
+
|
|
381
|
+
const problems: string[] = [];
|
|
382
|
+
const pending: string[] = [];
|
|
383
|
+
const notes: string[] = [];
|
|
384
|
+
|
|
385
|
+
// The lock: the overrides map, and the declared libraries' entries. A library's name is replaced by the map too.
|
|
386
|
+
const lock = loadLock();
|
|
387
|
+
const overrides = lock.overrides ?? {};
|
|
388
|
+
const libraries = declaredLibraries(options.libraries ?? [], overrides);
|
|
389
|
+
for (const name of libraries) {
|
|
390
|
+
if (!lock.libraries[name])
|
|
391
|
+
notes.push(
|
|
392
|
+
`library "${name}" is declared, and the lock holds nothing for it: discover its variables, or pick its components, in the Build pane`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
const replaced = (name: string, label: string): string => {
|
|
396
|
+
const to = overrides[name];
|
|
397
|
+
if (to === undefined) return name;
|
|
398
|
+
notes.push(`${label} is read as ${keyIn(to) === undefined ? `"${to}"` : to}, as the overrides map says`);
|
|
399
|
+
return to;
|
|
400
|
+
};
|
|
401
|
+
const inLibraries = <T>(pick: (library: LockedLibrary) => T | undefined): { library: string; entry: T }[] => {
|
|
402
|
+
const found: { library: string; entry: T }[] = [];
|
|
403
|
+
for (const library of libraries) {
|
|
404
|
+
const entry = lock.libraries[library] && pick(lock.libraries[library]);
|
|
405
|
+
if (entry !== undefined) found.push({ library, entry });
|
|
406
|
+
}
|
|
407
|
+
return found;
|
|
408
|
+
};
|
|
409
|
+
const quoted = (names: string[]): string => names.map((name) => `"${name}"`).join(' and ');
|
|
410
|
+
const both = (label: string, here: string, from: { library: string }[]): string =>
|
|
411
|
+
`${label} is in this file (${here}) and in library ${quoted(from.map((f) => f.library))}: a name found in both stops the build; rename one, or name the one to use in the overrides map`;
|
|
412
|
+
const several = (label: string, from: { library: string }[]): string =>
|
|
413
|
+
`${label} is in libraries ${quoted(from.map((f) => f.library))}: name the one to use in the overrides map, as (key)"…"`;
|
|
414
|
+
// Importing writes to the file, so a dry run says what it would import and imports nothing.
|
|
415
|
+
const importing = async (label: string, from: string, load: () => Promise<void>): Promise<void> => {
|
|
416
|
+
if (options.dryRun) return void pending.push(`${label}: would import ${from}`);
|
|
417
|
+
try {
|
|
418
|
+
await load();
|
|
419
|
+
} catch {
|
|
420
|
+
problems.push(`${label}: ${from} did not import: ${NOT_IMPORTED}`);
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
// The page
|
|
425
|
+
let page: PageNode | undefined;
|
|
426
|
+
if (options.page === undefined) {
|
|
427
|
+
page = figma.currentPage;
|
|
428
|
+
} else {
|
|
429
|
+
page = figma.root.children.find((candidate) => candidate.name === options.page);
|
|
430
|
+
if (!page) problems.push(`no page named "${options.page}" in this file`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const collections = await figma.variables.getLocalVariableCollectionsAsync();
|
|
434
|
+
const variables = await figma.variables.getLocalVariablesAsync();
|
|
435
|
+
|
|
436
|
+
// Tokens: a path is looked for by the name the variable sync writes, `tokenPathToVariableName(path)`, in every collection.
|
|
437
|
+
// A name found in two collections stops the build; there is no syntax for choosing.
|
|
438
|
+
const floats = new Map<string, Variable>();
|
|
439
|
+
const colors = new Map<string, Variable>();
|
|
440
|
+
const textStyles = new Map<string, TextStyle>();
|
|
441
|
+
const effectStyles = new Map<string, EffectStyle>();
|
|
442
|
+
|
|
443
|
+
// A variable an earlier step of a dry run would remove, because its token moves to another collection, is not counted.
|
|
444
|
+
const removing = options.pending?.removed ?? [];
|
|
445
|
+
const variableMatches = (path: string): { collection: VariableCollection; variable: Variable }[] => {
|
|
446
|
+
const found: { collection: VariableCollection; variable: Variable }[] = [];
|
|
447
|
+
const name = tokenPathToVariableName(path);
|
|
448
|
+
for (const collection of collections) {
|
|
449
|
+
if (removing.some((gone) => gone.collection === collection.name && gone.name === name)) continue;
|
|
450
|
+
for (const variable of variables) {
|
|
451
|
+
if (variable.variableCollectionId === collection.id && variable.name === name) found.push({ collection, variable });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return found;
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
// Among the variables of one token's name, the one the sync stamped for it in the collection it lives in, and one
|
|
458
|
+
// stamped for it in another: written by an earlier sync, before the token moved.
|
|
459
|
+
const olderFrom = (path: string, matches: { collection: VariableCollection; variable: Variable }[]): string => {
|
|
460
|
+
const { resolver, conversionMap } = getState();
|
|
461
|
+
if (!resolver || matches.length < 2) return '';
|
|
462
|
+
let lookup: string;
|
|
463
|
+
let home: string | undefined;
|
|
464
|
+
try {
|
|
465
|
+
const { dsCollectionName } = resolveSumModes(resolver);
|
|
466
|
+
lookup = `${dsCollectionName}.${path}`;
|
|
467
|
+
home = buildSum(resolver, dsCollectionName, conversionMap).tokenByPath.get(lookup)?.variables[0]?.collection;
|
|
468
|
+
} catch {
|
|
469
|
+
return '';
|
|
470
|
+
}
|
|
471
|
+
// A person's copy carries the stamp of the variable it was made from, and is not one the sync wrote.
|
|
472
|
+
const stamped = (m: { variable: Variable }) => {
|
|
473
|
+
const stamp = getStamp(m.variable);
|
|
474
|
+
return stamp?.is.token === lookup && isOwn(m.variable, stamp);
|
|
475
|
+
};
|
|
476
|
+
const current = matches.find((m) => stamped(m) && m.collection.name === home);
|
|
477
|
+
const older = matches.find((m) => m !== current && stamped(m));
|
|
478
|
+
return current && older
|
|
479
|
+
? `; the one in "${current.collection.name}" is the one the sync recorded for this token, and the one in "${older.collection.name}" was written by an earlier sync, from before the token moved: delete it in Figma`
|
|
480
|
+
: '';
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
// What an earlier step would create is looked up by the same rule, so a dry run and the real run that follows it agree.
|
|
484
|
+
const pendingVariables = (path: string): NonNullable<RunOptions['pending']>['variables'] =>
|
|
485
|
+
(options.pending?.variables ?? []).filter((entry) => entry.name === tokenPathToVariableName(path));
|
|
486
|
+
|
|
487
|
+
// A library's variable is kept by its collection and name, which only the loaded document gives.
|
|
488
|
+
const sum = sumOfLoadedDocument();
|
|
489
|
+
const slotOf = variableSlots(sum);
|
|
490
|
+
const libraryVariable = (path: string, label: string): { library: string; entry: { id: string; key: string } }[] | undefined => {
|
|
491
|
+
if (inLibraries((library) => (Object.keys(library.variables).length > 0 ? library : undefined)).length === 0) return [];
|
|
492
|
+
if (!slotOf) {
|
|
493
|
+
problems.push(
|
|
494
|
+
`${label} may be a library's, and the lock keeps a library's variables by the collection the variable sync gives each token; no design system is loaded to say which: load its resolver document`,
|
|
495
|
+
);
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
const slot = slotOf(path);
|
|
499
|
+
const id = slot && `${slot.collection}/${slot.name}`;
|
|
500
|
+
return id === undefined
|
|
501
|
+
? []
|
|
502
|
+
: inLibraries((library) => (library.variables[id] === undefined ? undefined : { id, key: library.variables[id] }));
|
|
503
|
+
};
|
|
504
|
+
const importVariable = (
|
|
505
|
+
path: string,
|
|
506
|
+
label: string,
|
|
507
|
+
key: string,
|
|
508
|
+
from: string,
|
|
509
|
+
type: VariableResolvedDataType,
|
|
510
|
+
into: Map<string, Variable>,
|
|
511
|
+
what: string,
|
|
512
|
+
): Promise<void> =>
|
|
513
|
+
importing(label, from, async () => {
|
|
514
|
+
const variable = await figma.variables.importVariableByKeyAsync(key);
|
|
515
|
+
if (variable.resolvedType !== type) problems.push(`${label} is a ${variable.resolvedType} variable, but ${what} needs a ${type} one`);
|
|
516
|
+
else into.set(path, variable);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// `written` is the path as the file wrote it, which the engine asks for; `path` is what the overrides map replaced it with.
|
|
520
|
+
const lookVariable = async (
|
|
521
|
+
written: string,
|
|
522
|
+
where: Wanted,
|
|
523
|
+
type: VariableResolvedDataType,
|
|
524
|
+
into: Map<string, Variable>,
|
|
525
|
+
what: string,
|
|
526
|
+
): Promise<void> => {
|
|
527
|
+
const label = `token ${pathKey(written)}${at(where)}`;
|
|
528
|
+
const path = replaced(written, label);
|
|
529
|
+
const key = keyIn(path);
|
|
530
|
+
if (key !== undefined) return importVariable(written, label, key, `(key)"${key}" from the overrides map`, type, into, what);
|
|
531
|
+
const fromLibrary = libraryVariable(path, label);
|
|
532
|
+
if (fromLibrary === undefined) return;
|
|
533
|
+
const matches = variableMatches(path);
|
|
534
|
+
if (fromLibrary.length > 0) {
|
|
535
|
+
if (matches.length > 0)
|
|
536
|
+
problems.push(both(label, matches.map((m) => `${m.collection.name}: "${m.variable.name}"`).join('; '), fromLibrary));
|
|
537
|
+
else if (fromLibrary.length > 1) problems.push(several(label, fromLibrary));
|
|
538
|
+
else
|
|
539
|
+
return importVariable(
|
|
540
|
+
written,
|
|
541
|
+
label,
|
|
542
|
+
fromLibrary[0].entry.key,
|
|
543
|
+
`"${fromLibrary[0].entry.id}" from library "${fromLibrary[0].library}"`,
|
|
544
|
+
type,
|
|
545
|
+
into,
|
|
546
|
+
what,
|
|
547
|
+
);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
// A pending name that is already in the file is the variable in the file, not a second one.
|
|
551
|
+
const coming = pendingVariables(path).filter(
|
|
552
|
+
(entry) => !matches.some((m) => m.collection.name === entry.collection && m.variable.name === entry.name),
|
|
553
|
+
);
|
|
554
|
+
if (matches.length === 0 && coming.length === 1 && coming[0].type === type) {
|
|
555
|
+
pending.push(`${label}: the variable sync would create "${coming[0].name}"`);
|
|
556
|
+
} else if (matches.length + coming.length === 0) {
|
|
557
|
+
const tried =
|
|
558
|
+
collections.length > 0
|
|
559
|
+
? `"${tokenPathToVariableName(path)}" in ${collections.map((c) => `"${c.name}"`).join(', ')}`
|
|
560
|
+
: 'no collection';
|
|
561
|
+
problems.push(`${label} names no variable: looked for ${tried}`);
|
|
562
|
+
} else if (matches.length + coming.length > 1) {
|
|
563
|
+
const names = [
|
|
564
|
+
...matches.map((m) => `${m.collection.name}: "${m.variable.name}"`),
|
|
565
|
+
...coming.map((c) => `${c.collection}: "${c.name}" (the sync would create it)`),
|
|
566
|
+
].join('; ');
|
|
567
|
+
problems.push(
|
|
568
|
+
`${label} is ambiguous, it matches ${names}; a design system whose names collide has a naming problem for you to fix${olderFrom(path, matches)}`,
|
|
569
|
+
);
|
|
570
|
+
} else if (matches.length === 0) {
|
|
571
|
+
problems.push(`${label} is a ${coming[0].type} variable the sync would create, but ${what} needs a ${type} one`);
|
|
572
|
+
} else if (matches[0].variable.resolvedType !== type) {
|
|
573
|
+
problems.push(`${label} is a ${matches[0].variable.resolvedType} variable, but ${what} needs a ${type} one`);
|
|
574
|
+
} else {
|
|
575
|
+
into.set(written, matches[0].variable);
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
// Gradients and effects (unit 4). A paint token the design system says is a gradient, and every token on `effects`, is
|
|
579
|
+
// read from the core at the declared defaults: the Figma objects it makes, and the tokens its members refer to. A
|
|
580
|
+
// member that refers to a token with a variable is bound to that variable; any other part is written as its value.
|
|
581
|
+
const core = coreAtDefaults();
|
|
582
|
+
const needs = ' and no design system is loaded; load its resolver document in the plugin, or pass it to the command line';
|
|
583
|
+
const composites = new Map<string, Composite>();
|
|
584
|
+
// A member's token is bound when the locator would find a variable for it: the overrides map names one, a declared
|
|
585
|
+
// library holds it, a variable in the file has its name, or a dry run's sync would make one. The lookup below then holds it to its rules.
|
|
586
|
+
const bindable = (path: string, where: Wanted, into: Map<string, Wanted>): boolean => {
|
|
587
|
+
const named = overrides[path] !== undefined || (libraryVariable(path, `token ${pathKey(path)}${at(where)}`)?.length ?? 0) > 0;
|
|
588
|
+
if (!named && variableMatches(path).length + pendingVariables(path).length === 0) return false;
|
|
589
|
+
if (!into.has(path)) into.set(path, where);
|
|
590
|
+
return true;
|
|
591
|
+
};
|
|
592
|
+
const unreadable = (path: string, where: Wanted, what: string, error: unknown): void =>
|
|
593
|
+
void problems.push(`token ${pathKey(path)}${at(where)} is a ${what} Figma cannot take: ${(error as Error).message}`);
|
|
594
|
+
if (core) {
|
|
595
|
+
for (const [path, where] of [...wanted.colors].filter(([candidate]) => core.valueOf(candidate)?.type === 'gradient')) {
|
|
596
|
+
wanted.colors.delete(path);
|
|
597
|
+
const value = core.raw(path);
|
|
598
|
+
let paint: GradientPaint;
|
|
599
|
+
try {
|
|
600
|
+
paint = toPaintStyleProps(value).paints[0] as GradientPaint;
|
|
601
|
+
} catch (error) {
|
|
602
|
+
unreadable(path, where, 'gradient', error);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
// A W3C gradient is a list of stops and has no angle: it runs left to right. Toghen's object form may carry one.
|
|
606
|
+
const angled =
|
|
607
|
+
typeof value === 'object' && value !== null && !Array.isArray(value) && (value as { angle?: unknown }).angle !== undefined;
|
|
608
|
+
const references = core.references(path);
|
|
609
|
+
const bound: Record<string, string> = {};
|
|
610
|
+
const written = angled ? ['stop positions', 'angle'] : ['stop positions'];
|
|
611
|
+
paint.gradientStops.forEach((_stop, index) => {
|
|
612
|
+
const target = references[`${index}.color`] ?? references[`stops.${index}.color`];
|
|
613
|
+
if (target !== undefined && bindable(target, where, wanted.colors)) bound[`${index}.color`] = target;
|
|
614
|
+
else written.push(`colour of stop ${index + 1}`);
|
|
615
|
+
});
|
|
616
|
+
composites.set(path, { paint, bound, written, context: core.context });
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
for (const [path, where] of wanted.effects) {
|
|
620
|
+
const type = core?.valueOf(path)?.type;
|
|
621
|
+
if (!core) problems.push(`token ${pathKey(path)}${at(where)} on "effects" is a shadow or a blur, as the design system says,${needs}`);
|
|
622
|
+
else if (type === 'dimension') {
|
|
623
|
+
// A layer blur, its radius bound to the token's variable.
|
|
624
|
+
wanted.floats.set(path, wanted.floats.get(path) ?? where);
|
|
625
|
+
const radius = core.valueOf(path)?.value as number;
|
|
626
|
+
composites.set(path, {
|
|
627
|
+
effects: [{ type: 'LAYER_BLUR', blurType: 'NORMAL', radius, visible: true }],
|
|
628
|
+
bound: { '0.radius': path },
|
|
629
|
+
written: [],
|
|
630
|
+
context: core.context,
|
|
631
|
+
});
|
|
632
|
+
} else if (type === 'shadow') {
|
|
633
|
+
const value = core.raw(path);
|
|
634
|
+
let effects: Effect[];
|
|
635
|
+
try {
|
|
636
|
+
effects = toEffectStyleProps(value).effects;
|
|
637
|
+
} catch (error) {
|
|
638
|
+
unreadable(path, where, 'shadow', error);
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
const shadows = (Array.isArray(value) ? value : [value]) as Record<string, unknown>[];
|
|
642
|
+
const references = core.references(path);
|
|
643
|
+
const bound: Record<string, string> = {};
|
|
644
|
+
const written: string[] = [];
|
|
645
|
+
shadows.forEach((shadow, index) => {
|
|
646
|
+
for (const [member, field] of Object.entries(SHADOW_FIELDS)) {
|
|
647
|
+
if (shadow[member] === undefined) continue;
|
|
648
|
+
const target = references[Array.isArray(value) ? `${index}.${member}` : member];
|
|
649
|
+
if (target !== undefined && bindable(target, where, field === 'color' ? wanted.colors : wanted.floats))
|
|
650
|
+
bound[`${index}.${field}`] = target;
|
|
651
|
+
else if (!written.includes(member)) written.push(member);
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
composites.set(path, { effects, bound, written, context: core.context });
|
|
655
|
+
} else if (type === undefined)
|
|
656
|
+
problems.push(`token ${pathKey(path)}${at(where)} on "effects": the loaded design system has no such token`);
|
|
657
|
+
else
|
|
658
|
+
problems.push(
|
|
659
|
+
`token ${pathKey(path)}${at(where)} is a ${type}, and "effects" takes a shadow, or a dimension for a layer blur's radius`,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
for (const [path, where] of wanted.floats) await lookVariable(path, where, 'FLOAT', floats, 'a dimension or opacity');
|
|
664
|
+
for (const [path, where] of wanted.colors) await lookVariable(path, where, 'COLOR', colors, 'a fill or stroke');
|
|
665
|
+
|
|
666
|
+
// Text fields, `visible`, `characters`, instance properties and property defaults: by the variable type each binds.
|
|
667
|
+
const strings = new Map<string, Variable>();
|
|
668
|
+
const booleans = new Map<string, Variable>();
|
|
669
|
+
const typedInto: Partial<Record<VariableResolvedDataType, Map<string, Variable>>> = {
|
|
670
|
+
FLOAT: floats,
|
|
671
|
+
STRING: strings,
|
|
672
|
+
BOOLEAN: booleans,
|
|
673
|
+
COLOR: colors,
|
|
674
|
+
};
|
|
675
|
+
const lookTyped = async (path: string, where: Wanted, type: VariableResolvedDataType): Promise<void> => {
|
|
676
|
+
if (!typedInto[type]!.has(path)) await lookVariable(path, where, type, typedInto[type]!, `a field bound to a ${type} variable`);
|
|
677
|
+
};
|
|
678
|
+
for (const entry of wanted.typed.values()) await lookTyped(entry.path, entry, entry.type);
|
|
679
|
+
|
|
680
|
+
// A line height or letter spacing in pixels is bound; in any other unit it is written as its value, at the declared
|
|
681
|
+
// defaults. Only the core can say which: the unit is the token's, and a variable holds a bare number.
|
|
682
|
+
const textValues = new Map<string, { value: LineHeight | LetterSpacing; context: string }>();
|
|
683
|
+
for (const [key, where] of wanted.metrics) {
|
|
684
|
+
const { field, path } = where;
|
|
685
|
+
const token = core?.valueOf(path) === undefined ? undefined : { value: core.raw(path) };
|
|
686
|
+
const said = `token ${pathKey(path)}${at(where)} on "${field}" is bound in pixels or written as its value, as its unit says`;
|
|
687
|
+
if (!core || !token) {
|
|
688
|
+
problems.push(
|
|
689
|
+
`${said}, and ${core ? 'the loaded design system has no such token' : 'no design system is loaded; load its resolver document in the plugin, or pass it to the command line'}`,
|
|
690
|
+
);
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
let figmaValue: LineHeight | LetterSpacing;
|
|
694
|
+
try {
|
|
695
|
+
figmaValue = field === 'lineHeight' ? toFigmaLineHeight(token.value) : toFigmaLetterSpacing(token.value);
|
|
696
|
+
} catch (error) {
|
|
697
|
+
problems.push(`token ${pathKey(path)}${at(where)} on "${field}": ${(error as Error).message}`);
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (figmaValue.unit === 'PIXELS') {
|
|
701
|
+
await lookTyped(path, where, 'FLOAT');
|
|
702
|
+
const fields = wanted.bound.get(path) ?? wanted.bound.set(path, new Map()).get(path)!;
|
|
703
|
+
if (!fields.has(field)) fields.set(field, { text: true, where });
|
|
704
|
+
} else {
|
|
705
|
+
textValues.set(key, { value: figmaValue, context: core.context });
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// What a variable is for is held against what binds it. A unit that does not fit draws wrongly (an opacity bound to a
|
|
710
|
+
// width is 1px, a plain number bound to opacity is 1%), so it stops the component; a scope that does not fit still draws.
|
|
711
|
+
if (sum) {
|
|
712
|
+
for (const [path, byField] of wanted.bound) {
|
|
713
|
+
const token = sum.tokenByPath.get(`${sum.dsName}.${path}`);
|
|
714
|
+
const purpose = token?.routing.kind === 'variable' ? token.purpose : undefined;
|
|
715
|
+
if (!purpose) continue;
|
|
716
|
+
for (const [field, { text, where }] of byField) {
|
|
717
|
+
const place = `token ${sum.dsName}.${path}${where.member !== undefined ? ` in member "${where.member}"` : ''}`;
|
|
718
|
+
const line = where.loc ? ` (line ${where.loc.line})` : '';
|
|
719
|
+
if (field === 'opacity' && !purpose.opacity) {
|
|
720
|
+
problems.push(`${place} is bound to opacity but is not an opacity; declare figma.scopes ["OPACITY"] on it${line}`);
|
|
721
|
+
} else if (field !== 'opacity' && purpose.opacity) {
|
|
722
|
+
problems.push(`${place} is an opacity, bound to ${field}${line}`);
|
|
723
|
+
} else if (purpose.scopes.length > 0) {
|
|
724
|
+
const fits = scopesOfField(field, text);
|
|
725
|
+
const offered = (scope: VariableScope): boolean =>
|
|
726
|
+
purpose.scopes.includes('ALL_SCOPES') ||
|
|
727
|
+
purpose.scopes.includes(scope) ||
|
|
728
|
+
(purpose.scopes.includes('ALL_FILLS') && /_FILL$/.test(scope));
|
|
729
|
+
if (fits.length > 0 && !fits.some(offered)) {
|
|
730
|
+
notes.push(
|
|
731
|
+
`${sum.dsName}.${path} is bound to ${field} but is offered to ${purpose.scopes.join(', ')} only; declare figma.scopes to widen it.`,
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// What Figma cannot bind is written as the token's value at the declared defaults, which only the core answers. A
|
|
740
|
+
// colour's alpha is such a part: a paint's opacity does not bind. Without the core it cannot be known, so a colour
|
|
741
|
+
// whose variable holds an alpha below 1 stops the run, as does any token on a field with no binding.
|
|
742
|
+
const values = new Map<string, { value: VariableValue | number[]; context: string }>();
|
|
743
|
+
const valueAt = (path: string, where: Wanted, what: string): void => {
|
|
744
|
+
if (!core) return void problems.push(`token ${pathKey(path)}${at(where)} ${what},${needs}`);
|
|
745
|
+
const answer = core.valueOf(path);
|
|
746
|
+
// ponytail: a token is held against the first field the file writes it on; another field of another type is not checked.
|
|
747
|
+
const types = (FIELDS[(where as { field?: string }).field ?? '']?.token?.types ?? []) as readonly string[];
|
|
748
|
+
if (answer === undefined) problems.push(`token ${pathKey(path)}${at(where)} ${what}, and the loaded design system has no such token`);
|
|
749
|
+
else if (types.length > 0 && !types.includes(answer.type)) {
|
|
750
|
+
problems.push(
|
|
751
|
+
`token ${pathKey(path)}${at(where)} ${what}; it is a ${answer.type || 'token with no type'}, and "${(where as { field: string }).field}" takes a ${types.join(' or a ')}`,
|
|
752
|
+
);
|
|
753
|
+
} else if (answer.value === undefined)
|
|
754
|
+
problems.push(`token ${pathKey(path)}${at(where)} ${what}, and its value is not one Figma can take there`);
|
|
755
|
+
else values.set(path, { value: answer.value, context: core.context });
|
|
756
|
+
};
|
|
757
|
+
for (const [path, where] of wanted.values) valueAt(path, where, `on "${where.field}" is written as its value, which Figma cannot bind`);
|
|
758
|
+
for (const [path, where] of wanted.colors) {
|
|
759
|
+
const variable = colors.get(path);
|
|
760
|
+
const translucent =
|
|
761
|
+
!!variable && Object.values(variable.valuesByMode).some((value) => typeof value === 'object' && 'a' in value && value.a < 1);
|
|
762
|
+
const value = core?.valueOf(path)?.value;
|
|
763
|
+
if (value !== undefined) values.set(path, { value, context: core!.context });
|
|
764
|
+
else if (translucent) valueAt(path, where, "has an alpha, written as the paint's opacity, which Figma cannot bind");
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// A style is named as a variable is, and no collection comes into it. A name the sync would create is found by the same rule.
|
|
768
|
+
// A token that is only a reference to a style names the style its target becomes, the chain followed.
|
|
769
|
+
const state = getState();
|
|
770
|
+
const targetOf = styleTargetOf(state.resolver, state.conversionMap);
|
|
771
|
+
// A library's style is kept by the token path it is named from: the target, as for a local one.
|
|
772
|
+
const lookStyle = async <S extends BaseStyle>(
|
|
773
|
+
what: string,
|
|
774
|
+
styleType: 'TEXT' | 'EFFECT',
|
|
775
|
+
wantedStyles: Map<string, Wanted>,
|
|
776
|
+
local: readonly S[],
|
|
777
|
+
pendingNames: string[] | undefined,
|
|
778
|
+
into: Map<string, S>,
|
|
779
|
+
): Promise<void> => {
|
|
780
|
+
const importStyle = (path: string, label: string, key: string, from: string): Promise<void> =>
|
|
781
|
+
importing(label, from, async () => {
|
|
782
|
+
const style = await figma.importStyleByKeyAsync(key);
|
|
783
|
+
if (style.type !== styleType) problems.push(`${label} is a ${style.type.toLowerCase()} style, not a ${what}`);
|
|
784
|
+
else into.set(path, style as S);
|
|
785
|
+
});
|
|
786
|
+
for (const [path, where] of wantedStyles) {
|
|
787
|
+
const label = `${what} ${pathKey(path)}${at(where)}`;
|
|
788
|
+
const written = replaced(path, label);
|
|
789
|
+
const key = keyIn(written);
|
|
790
|
+
if (key !== undefined) {
|
|
791
|
+
await importStyle(path, label, key, `(key)"${key}" from the overrides map`);
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
const target = targetOf(written, styleType);
|
|
795
|
+
const name = tokenPathToVariableName(target);
|
|
796
|
+
const matches = local.filter((style) => style.name === name);
|
|
797
|
+
const fromLibrary = inLibraries((library) => (library.styles[target]?.kind === styleType ? library.styles[target] : undefined));
|
|
798
|
+
if (fromLibrary.length > 0) {
|
|
799
|
+
if (matches.length > 0) problems.push(both(label, `"${name}"`, fromLibrary));
|
|
800
|
+
else if (fromLibrary.length > 1) problems.push(several(label, fromLibrary));
|
|
801
|
+
else await importStyle(path, label, fromLibrary[0].entry.key, `"${name}" from library "${fromLibrary[0].library}"`);
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
const coming = (pendingNames ?? []).includes(name) && matches.length === 0 ? [name] : [];
|
|
805
|
+
if (matches.length === 0 && coming.length === 1) pending.push(`${label}: the style sync would create "${coming[0]}"`);
|
|
806
|
+
else if (matches.length + coming.length === 0) problems.push(`${label} names no ${what}`);
|
|
807
|
+
else if (matches.length + coming.length > 1) {
|
|
808
|
+
problems.push(
|
|
809
|
+
`${label} is ambiguous, it matches ${[...matches.map((m) => `"${m.name}"`), ...coming.map((c) => `"${c}" (the sync would create it)`)].join(', ')}`,
|
|
810
|
+
);
|
|
811
|
+
} else into.set(path, matches[0]);
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
await lookStyle('text style', 'TEXT', wanted.textStyles, await figma.getLocalTextStylesAsync(), options.pending?.textStyles, textStyles);
|
|
815
|
+
await lookStyle(
|
|
816
|
+
'effect style',
|
|
817
|
+
'EFFECT',
|
|
818
|
+
wanted.effectStyles,
|
|
819
|
+
await figma.getLocalEffectStylesAsync(),
|
|
820
|
+
options.pending?.effectStyles,
|
|
821
|
+
effectStyles,
|
|
822
|
+
);
|
|
823
|
+
|
|
824
|
+
// Modes: a collection by name, local or a declared library's, and a mode of it by name. A library's collection is the
|
|
825
|
+
// prefix of its variables' ids in the lock; its modes are not in the lock, so the target says whether the mode is there.
|
|
826
|
+
const modes = new Map<string, ModePin>();
|
|
827
|
+
for (const [key, where] of wanted.modes) {
|
|
828
|
+
const at_ = key.indexOf('=');
|
|
829
|
+
const collectionName = key.slice(0, at_);
|
|
830
|
+
const modeName = key.slice(at_ + 1);
|
|
831
|
+
const label = `modes ${key}${at(where)}`;
|
|
832
|
+
const named = collections.filter((c) => c.name === collectionName);
|
|
833
|
+
const holding = inLibraries((library) =>
|
|
834
|
+
Object.keys(library.variables).some((id) => id.startsWith(`${collectionName}/`)) ? library : undefined,
|
|
835
|
+
);
|
|
836
|
+
// A dry run counts a mode the variable sync would create; a real run builds against what the file holds.
|
|
837
|
+
const coming = (options.pending?.modes ?? []).some((m) => m.collection === collectionName && m.mode === modeName);
|
|
838
|
+
if (holding.length > 0 && (named.length > 0 || coming)) {
|
|
839
|
+
problems.push(
|
|
840
|
+
`${label}: the collection "${collectionName}" is in this file and in library ${quoted(holding.map((h) => h.library))}: a name found in both stops the build; rename one`,
|
|
841
|
+
);
|
|
842
|
+
} else if (holding.length > 1) {
|
|
843
|
+
problems.push(
|
|
844
|
+
`${label}: the collection "${collectionName}" is in libraries ${quoted(holding.map((h) => h.library))}: a name found in two stops the build; rename one, or declare only one of them`,
|
|
845
|
+
);
|
|
846
|
+
} else if (holding.length === 1) {
|
|
847
|
+
const { library, entry } = holding[0];
|
|
848
|
+
if (!libraryModes)
|
|
849
|
+
problems.push(
|
|
850
|
+
`${label}: a library collection's mode ("${collectionName}" in library "${library}") is not measured in Figma yet; pin it on FigCraft, or use a local collection`,
|
|
851
|
+
);
|
|
852
|
+
else
|
|
853
|
+
modes.set(key, {
|
|
854
|
+
library: { name: library, ...(entry.id !== undefined ? { id: entry.id } : {}) },
|
|
855
|
+
collection: { id: `library:${library}/${collectionName}`, name: collectionName },
|
|
856
|
+
modeId: modeName,
|
|
857
|
+
where: label,
|
|
858
|
+
});
|
|
859
|
+
} else if (coming && (named.length === 0 || (named.length === 1 && !named[0].modes.some((m) => m.name === modeName)))) {
|
|
860
|
+
pending.push(`${label}: the variable sync would create it`);
|
|
861
|
+
} else if (named.length === 0) {
|
|
862
|
+
const here = collections.length > 0 ? `this file has ${collections.map((c) => `"${c.name}"`).join(', ')}` : 'this file has none';
|
|
863
|
+
const looked =
|
|
864
|
+
libraries.length === 0
|
|
865
|
+
? 'and no library is declared'
|
|
866
|
+
: `and ${libraries.length === 1 ? 'library' : 'libraries'} ${quoted(libraries)} ${libraries.length === 1 ? 'holds' : 'hold'} none`;
|
|
867
|
+
problems.push(`${label}: no local variable collection named "${collectionName}" (${here}), ${looked}`);
|
|
868
|
+
} else if (named.length > 1) {
|
|
869
|
+
problems.push(`${label}: ${named.length} local collections are named "${collectionName}"`);
|
|
870
|
+
} else {
|
|
871
|
+
const mode = named[0].modes.find((m) => m.name === modeName);
|
|
872
|
+
if (!mode)
|
|
873
|
+
problems.push(
|
|
874
|
+
`${label}: "${collectionName}" has no mode "${modeName}" (it has ${named[0].modes.map((m) => `"${m.name}"`).join(', ')})`,
|
|
875
|
+
);
|
|
876
|
+
else modes.set(key, { collection: named[0], modeId: mode.modeId });
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Components: by name, exactly one, in this file or in a declared library's lock entry; or by published key.
|
|
881
|
+
const components = new Map<string, ComponentNode>();
|
|
882
|
+
const sources = new Map<string, { name: string; key?: string; library?: string }>();
|
|
883
|
+
// A key names a component or a set; `set` says which when the lock knows.
|
|
884
|
+
const importComponent = async (ref: ComponentRef, key: string, set?: boolean): Promise<void> => {
|
|
885
|
+
if (set !== true) {
|
|
886
|
+
try {
|
|
887
|
+
return void components.set(componentKey(ref), await figma.importComponentByKeyAsync(key));
|
|
888
|
+
} catch (notAComponent) {
|
|
889
|
+
if (set === false) throw notAComponent;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
// unmeasured: that a key names either a component or a set, and that the component call is the one that fails for a set
|
|
893
|
+
components.set(componentKey(ref), (await figma.importComponentSetByKeyAsync(key)).defaultVariant);
|
|
894
|
+
};
|
|
895
|
+
const local = localComponents();
|
|
896
|
+
const attempted = new Set<string>();
|
|
897
|
+
const lookupComponent = async (ref: ComponentRef, entry: Wanted): Promise<void> => {
|
|
898
|
+
const key = componentKey(ref);
|
|
899
|
+
if (attempted.has(key)) return;
|
|
900
|
+
attempted.add(key);
|
|
901
|
+
if ('name' in ref) {
|
|
902
|
+
const label = `component "${ref.name}"${at(entry)}`;
|
|
903
|
+
replaced(ref.name, label);
|
|
904
|
+
const located = locateComponent(ref.name, libraries, lock, local);
|
|
905
|
+
const ids = (nodes: SceneNode[]): string => nodes.map((m) => m.id).join(', ');
|
|
906
|
+
const named = (from: string[]): { library: string }[] => from.map((library) => ({ library }));
|
|
907
|
+
if (located.at === 'key') {
|
|
908
|
+
sources.set(key, { name: located.name, key: located.key });
|
|
909
|
+
return importing(label, `(key)"${located.key}" from the overrides map`, () => importComponent(ref, located.key));
|
|
910
|
+
}
|
|
911
|
+
if (located.at === 'both') problems.push(both(label, ids(located.here), named(located.libraries)));
|
|
912
|
+
else if (located.at === 'several') problems.push(several(label, named(located.libraries)));
|
|
913
|
+
else if (located.at === 'library') {
|
|
914
|
+
sources.set(key, { name: located.name, key: located.key, library: located.library });
|
|
915
|
+
await importing(
|
|
916
|
+
label,
|
|
917
|
+
`${located.set ? 'the component set' : 'the component'} "${located.name}" from library "${located.library}"`,
|
|
918
|
+
() => importComponent(ref, located.key, located.set),
|
|
919
|
+
);
|
|
920
|
+
} else if (located.at === 'missing' && options.dryRun && options.pending?.components?.includes(located.name)) {
|
|
921
|
+
// A set a file earlier in the same dry run would create (ruling 7a): its name is checked, and what is set on its instances is not yet.
|
|
922
|
+
pending.push(
|
|
923
|
+
`${label}: a file earlier in this run would create it; the properties and slots of instances of "${located.name}" are checked when it is built`,
|
|
924
|
+
);
|
|
925
|
+
} else if (located.at === 'missing') problems.push(`${label} is not in this file`);
|
|
926
|
+
else if (located.at === 'ambiguous')
|
|
927
|
+
problems.push(`${label} is ambiguous, this file has ${located.here.length} of that name (${ids(located.here)})`);
|
|
928
|
+
else {
|
|
929
|
+
sources.set(key, { name: located.name });
|
|
930
|
+
const node = located.node as ComponentNode | ComponentSetNode;
|
|
931
|
+
components.set(key, node.type === 'COMPONENT_SET' ? node.defaultVariant : node);
|
|
932
|
+
}
|
|
933
|
+
} else if (options.dryRun) {
|
|
934
|
+
// Importing writes to the file, and a dry run writes nothing: a key is not checked.
|
|
935
|
+
return;
|
|
936
|
+
} else {
|
|
937
|
+
try {
|
|
938
|
+
await importComponent(ref, ref.key);
|
|
939
|
+
} catch (_notASet) {
|
|
940
|
+
problems.push(
|
|
941
|
+
`component key "${ref.key}"${at(entry)} is neither a published component nor a published component set: ${NOT_IMPORTED}`,
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
for (const entry of wanted.components.values()) await lookupComponent(entry.ref, entry);
|
|
947
|
+
// A preferred value by name is answered as an instance swap's default is: a set gives its default variant to a
|
|
948
|
+
// COMPONENT, and itself to a COMPONENT_SET, which a component that is no set cannot answer.
|
|
949
|
+
const preferredNode = (value: PreferredValue): ComponentNode | ComponentSetNode | undefined => {
|
|
950
|
+
const node = 'name' in value ? components.get(componentKey({ name: value.name })) : undefined;
|
|
951
|
+
return node && value.type === 'COMPONENT_SET' && node.parent?.type === 'COMPONENT_SET' ? node.parent : node;
|
|
952
|
+
};
|
|
953
|
+
for (const property of desired.component.properties) {
|
|
954
|
+
for (const value of preferredOf(property)) {
|
|
955
|
+
if ('name' in value && value.type === 'COMPONENT_SET' && preferredNode(value)?.type === 'COMPONENT') {
|
|
956
|
+
problems.push(
|
|
957
|
+
`preferred COMPONENT_SET name="${value.name}"${at({ loc: value.loc })}: "${value.name}" is a component, not a set; write preferred COMPONENT`,
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Every instance is held against the component it is an instance of: the properties `set` names, and the slots it fills.
|
|
964
|
+
// A component file's members share their layers, so a problem found in several is said once.
|
|
965
|
+
for (const member of desired.members) await checkInstances(member.tree, components, lookupComponent, lookTyped, problems);
|
|
966
|
+
problems.splice(0, problems.length, ...new Set(problems));
|
|
967
|
+
|
|
968
|
+
if (problems.length > 0 || !page) {
|
|
969
|
+
throw new ComponentRunError(
|
|
970
|
+
`cannot build "${desired.component.name}": ${problems.length} thing${problems.length === 1 ? '' : 's'} the file names could not be resolved, and nothing was written`,
|
|
971
|
+
problems,
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// What a variable holds in its collection's default mode, its aliases followed.
|
|
976
|
+
const atDefault = (variable: Variable | undefined): VariableValue | undefined => {
|
|
977
|
+
for (let depth = 0; variable && depth < 16; depth++) {
|
|
978
|
+
const collection = collections.find((candidate) => candidate.id === variable!.variableCollectionId);
|
|
979
|
+
const value = variable.valuesByMode[collection?.defaultModeId ?? ''];
|
|
980
|
+
const alias = value as VariableAlias | undefined;
|
|
981
|
+
if (typeof alias !== 'object' || alias === null || alias.type !== 'VARIABLE_ALIAS') return value;
|
|
982
|
+
variable = variables.find((candidate) => candidate.id === alias.id);
|
|
983
|
+
}
|
|
984
|
+
return undefined;
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
const found = <T>(map: Map<string, T>, key: string, what: string): T => {
|
|
988
|
+
const value = map.get(key);
|
|
989
|
+
if (value === undefined) throw new Error(`${what} was not resolved: the engine asked for something the file does not name`);
|
|
990
|
+
return value;
|
|
991
|
+
};
|
|
992
|
+
return {
|
|
993
|
+
page,
|
|
994
|
+
float: (path) => found(floats, path, `token ${pathKey(path)}`),
|
|
995
|
+
color: (path) => found(colors, path, `token ${pathKey(path)}`),
|
|
996
|
+
textStyle: (path) => found(textStyles, path, `text style ${pathKey(path)}`),
|
|
997
|
+
effectStyle: (path) => found(effectStyles, path, `effect style ${pathKey(path)}`),
|
|
998
|
+
component: (ref) => found(components, componentKey(ref), `component ${JSON.stringify(ref)}`),
|
|
999
|
+
preferred: preferredNode,
|
|
1000
|
+
componentSource: (ref) =>
|
|
1001
|
+
sources.get(componentKey(ref)) ?? ('name' in ref ? { name: ref.name } : { name: `(key)"${ref.key}"`, key: ref.key }),
|
|
1002
|
+
mode: (collection, mode) => found(modes, `${collection}=${mode}`, `mode ${collection}=${mode}`),
|
|
1003
|
+
textStyles: () => [...textStyles.values()],
|
|
1004
|
+
pending,
|
|
1005
|
+
notes,
|
|
1006
|
+
valueOf: (path) => values.get(path),
|
|
1007
|
+
composite: (path) => composites.get(path),
|
|
1008
|
+
variable: (path) => found([floats, colors, strings, booleans].find((map) => map.has(path)) ?? floats, path, `token ${pathKey(path)}`),
|
|
1009
|
+
textValue: (field, path) => textValues.get(`${field} ${path}`),
|
|
1010
|
+
stack: (path) => {
|
|
1011
|
+
const families = core?.valueOf(path)?.type === 'fontFamily' ? fontStack(core.raw(path)) : [];
|
|
1012
|
+
return families.length > 1 ? families : undefined;
|
|
1013
|
+
},
|
|
1014
|
+
shows: (path) => atDefault(found(strings.has(path) ? strings : booleans, path, `token ${pathKey(path)}`)) as string | boolean,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* The loaded document's tokens at the declared defaults: the core's default mode, the same in every member. `valueOf`
|
|
1020
|
+
* is undefined for a path the document does not have; its `value` is undefined when Figma cannot take it.
|
|
1021
|
+
*/
|
|
1022
|
+
export function coreAtDefaults():
|
|
1023
|
+
| {
|
|
1024
|
+
context: string;
|
|
1025
|
+
valueOf(path: string): { type: string; value?: VariableValue | number[] } | undefined;
|
|
1026
|
+
/** The token's value, resolved, as the core gives it (unit 4). */
|
|
1027
|
+
raw(path: string): unknown;
|
|
1028
|
+
/** The references the token's members make, by member (`0.color`), as token paths: through a reference that is the whole value. */
|
|
1029
|
+
references(path: string): Record<string, string>;
|
|
1030
|
+
}
|
|
1031
|
+
| undefined {
|
|
1032
|
+
const { resolver } = getState();
|
|
1033
|
+
if (!resolver) return undefined;
|
|
1034
|
+
let sum: MultiModeResolution;
|
|
1035
|
+
try {
|
|
1036
|
+
sum = resolveSumModes(resolver);
|
|
1037
|
+
} catch {
|
|
1038
|
+
return undefined;
|
|
1039
|
+
}
|
|
1040
|
+
const lookup = (path: string): string => `${sum.dsCollectionName}.${path}`;
|
|
1041
|
+
return {
|
|
1042
|
+
context: sum.defaultMode,
|
|
1043
|
+
raw: (path) => readToken(sum.ds, lookup(path), sum.defaultMode)?.$value,
|
|
1044
|
+
references: (path) => {
|
|
1045
|
+
const out: Record<string, string> = {};
|
|
1046
|
+
let at = lookup(path);
|
|
1047
|
+
// A token whose whole value is a reference takes its members from the token it names; the core refuses a loop.
|
|
1048
|
+
for (;;) {
|
|
1049
|
+
const made = sum.ds.referencesOf(at, sum.defaultMode).filter((reference) => !reference.interpolated);
|
|
1050
|
+
const whole = made.find((reference) => reference.at === '');
|
|
1051
|
+
if (!whole) {
|
|
1052
|
+
for (const reference of made) out[reference.at] = reference.to.slice(sum.dsCollectionName.length + 1);
|
|
1053
|
+
return out;
|
|
1054
|
+
}
|
|
1055
|
+
at = whole.to;
|
|
1056
|
+
}
|
|
1057
|
+
},
|
|
1058
|
+
valueOf: (path) => {
|
|
1059
|
+
const token = readToken(sum.ds, lookup(path), sum.defaultMode);
|
|
1060
|
+
if (!token) return undefined;
|
|
1061
|
+
const type = token.$type ?? '';
|
|
1062
|
+
// A value the core refuses to compute is one Figma cannot take either.
|
|
1063
|
+
if (token.refused !== undefined) return { type };
|
|
1064
|
+
try {
|
|
1065
|
+
// A stroke style's dashes, for dashPattern; a keyword such as `dashed` names no lengths, and throws.
|
|
1066
|
+
if (type === 'strokeStyle')
|
|
1067
|
+
return { type, value: (token.$value as { dashArray: DimensionValue[] }).dashArray.map((dash) => normalizeDimensionToPx(dash)) };
|
|
1068
|
+
return { type, value: toFigmaValue(token.$value, type) };
|
|
1069
|
+
} catch {
|
|
1070
|
+
return { type };
|
|
1071
|
+
}
|
|
1072
|
+
},
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** The sum of the loaded document, when there is one and it can be built: what each variable is for. */
|
|
1077
|
+
function sumOfLoadedDocument(): Sum | undefined {
|
|
1078
|
+
const { resolver, conversionMap } = getState();
|
|
1079
|
+
if (!resolver) return undefined;
|
|
1080
|
+
try {
|
|
1081
|
+
return buildSum(resolver, resolver.name || 'resolved', conversionMap);
|
|
1082
|
+
} catch {
|
|
1083
|
+
return undefined;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
type Definitions = ComponentPropertyDefinitions;
|
|
1088
|
+
|
|
1089
|
+
/** The display name of a property: Figma suffixes the key of every property but a variant (`Name#12:0`), and the file never writes the suffix. */
|
|
1090
|
+
const displayName = (key: string): string => key.split('#')[0];
|
|
1091
|
+
|
|
1092
|
+
const describeValue = (value: SetField[string]['value']): string =>
|
|
1093
|
+
typeof value !== 'object' ? JSON.stringify(value) : 'token' in value ? `"{${value.token}}"` : `(key)${JSON.stringify(value.key)}`;
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* Holds every `INSTANCE` of a member or an artboard against its component: what `set` writes must be a property the component has,
|
|
1097
|
+
* of a kind that takes the value, and what `slot` fills must be a slot it has. Every problem is collected, with the line.
|
|
1098
|
+
*
|
|
1099
|
+
* The properties live on the set when the component is a variant of one (a variant member cannot be asked), and the
|
|
1100
|
+
* names are the display names, so a name that two properties share is ambiguous and refused.
|
|
1101
|
+
*/
|
|
1102
|
+
async function checkInstances(
|
|
1103
|
+
layer: Layer,
|
|
1104
|
+
components: Map<string, ComponentNode>,
|
|
1105
|
+
lookupComponent: (ref: ComponentRef, entry: Wanted) => Promise<void>,
|
|
1106
|
+
lookTyped: (path: string, where: Wanted, type: VariableResolvedDataType) => Promise<void>,
|
|
1107
|
+
problems: string[],
|
|
1108
|
+
): Promise<void> {
|
|
1109
|
+
const value = layer.fields.component as Value | undefined;
|
|
1110
|
+
const ref: ComponentRef | undefined =
|
|
1111
|
+
value?.kind === 'key'
|
|
1112
|
+
? { key: value.key }
|
|
1113
|
+
: value?.kind === 'plain' && typeof value.value === 'string'
|
|
1114
|
+
? { name: value.value }
|
|
1115
|
+
: undefined;
|
|
1116
|
+
const node = ref ? components.get(componentKey(ref)) : undefined;
|
|
1117
|
+
if (layer.kind === 'INSTANCE' && node) {
|
|
1118
|
+
const host = node.parent?.type === 'COMPONENT_SET' ? node.parent : node;
|
|
1119
|
+
const definitions: Definitions = host.componentPropertyDefinitions;
|
|
1120
|
+
const of = `INSTANCE "${layer.address}"`;
|
|
1121
|
+
const component = `component "${host.name}"`;
|
|
1122
|
+
const properties = Object.entries(definitions);
|
|
1123
|
+
const names = properties.map(([key]) => `"${displayName(key)}"`);
|
|
1124
|
+
|
|
1125
|
+
for (const [name, entry] of Object.entries((layer.fields.set as SetField | undefined) ?? {})) {
|
|
1126
|
+
const where = at({ loc: entry.loc ?? layer.fieldLoc?.set });
|
|
1127
|
+
const matches = properties.filter(([key]) => displayName(key) === name);
|
|
1128
|
+
if (matches.length === 0) {
|
|
1129
|
+
problems.push(
|
|
1130
|
+
`${of}${where}: ${component} has no property "${name}"${names.length > 0 ? ` (it has ${names.join(', ')})` : ' (it has none)'}`,
|
|
1131
|
+
);
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
if (matches.length > 1) {
|
|
1135
|
+
problems.push(
|
|
1136
|
+
`${of}${where}: ${component} has ${matches.length} properties named "${name}" (${matches.map(([, definition]) => definition.type).join(', ')}), so the name does not say which to set; rename one of them`,
|
|
1137
|
+
);
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
const [, definition] = matches[0];
|
|
1141
|
+
const given = entry.value;
|
|
1142
|
+
const wrong = (takes: string): void =>
|
|
1143
|
+
void problems.push(
|
|
1144
|
+
`${of}${where}: ${component} property "${name}" is a ${definition.type} and takes ${takes}, not ${describeValue(given)}`,
|
|
1145
|
+
);
|
|
1146
|
+
// A token binds its variable to a BOOLEAN or a TEXT property: a `boolean` token's, or a `string` token's.
|
|
1147
|
+
const token = typeof given === 'object' && 'token' in given ? given.token : undefined;
|
|
1148
|
+
if (token !== undefined && (definition.type === 'BOOLEAN' || definition.type === 'TEXT')) {
|
|
1149
|
+
await lookTyped(token, { loc: entry.loc }, definition.type === 'BOOLEAN' ? 'BOOLEAN' : 'STRING');
|
|
1150
|
+
} else if (token !== undefined) {
|
|
1151
|
+
wrong(definition.type === 'VARIANT' ? 'one of its values as text' : 'a component name or (key)"…"');
|
|
1152
|
+
} else if (definition.type === 'VARIANT') {
|
|
1153
|
+
const options = definition.variantOptions ?? [];
|
|
1154
|
+
if (typeof given !== 'string') wrong('one of its values as text');
|
|
1155
|
+
else if (!options.includes(given))
|
|
1156
|
+
problems.push(
|
|
1157
|
+
`${of}${where}: "${given}" is not a value of ${component} property "${name}" (${options.map((option) => `"${option}"`).join(', ')})`,
|
|
1158
|
+
);
|
|
1159
|
+
} else if (definition.type === 'BOOLEAN') {
|
|
1160
|
+
if (typeof given !== 'boolean') wrong('#true or #false');
|
|
1161
|
+
} else if (definition.type === 'TEXT') {
|
|
1162
|
+
if (typeof given !== 'string') wrong('text');
|
|
1163
|
+
} else if (definition.type === 'INSTANCE_SWAP') {
|
|
1164
|
+
if (typeof given === 'boolean') wrong('a component name or (key)"…"');
|
|
1165
|
+
else
|
|
1166
|
+
await lookupComponent(typeof given === 'object' ? { key: (given as { key: string }).key } : { name: given }, { loc: entry.loc });
|
|
1167
|
+
} else {
|
|
1168
|
+
problems.push(
|
|
1169
|
+
`${of}${where}: ${component} property "${name}" is a ${definition.type}, which is filled with \`slot ${name} { … }\` and not set`,
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const slots = new Map<string, Loc | undefined>();
|
|
1175
|
+
for (const slot of layer.slots ?? []) slots.set(slot.name, slot.loc);
|
|
1176
|
+
for (const child of layer.children) if (child.slot !== undefined && !slots.has(child.slot)) slots.set(child.slot, child.loc);
|
|
1177
|
+
for (const [name, loc] of slots) {
|
|
1178
|
+
const matches = properties.filter(([key]) => displayName(key) === name);
|
|
1179
|
+
const slotted = matches.filter(([, definition]) => definition.type === 'SLOT');
|
|
1180
|
+
const have = properties.filter(([, definition]) => definition.type === 'SLOT').map(([key]) => `"${displayName(key)}"`);
|
|
1181
|
+
if (slotted.length === 0) {
|
|
1182
|
+
problems.push(
|
|
1183
|
+
`${of}${at({ loc })}: ${component} has no slot "${name}"${matches.length > 0 ? ` ("${name}" is a ${matches[0][1].type} property)` : ''}${have.length > 0 ? ` (its slots are ${have.join(', ')})` : ' (it has no slots)'}`,
|
|
1184
|
+
);
|
|
1185
|
+
} else if (matches.length > 1) {
|
|
1186
|
+
problems.push(
|
|
1187
|
+
`${of}${at({ loc })}: ${component} has ${matches.length} properties named "${name}", so the name does not say which slot to fill; rename one of them`,
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
for (const child of layer.children) await checkInstances(child, components, lookupComponent, lookTyped, problems);
|
|
1193
|
+
}
|