@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,444 @@
|
|
|
1
|
+
import type { Resolver } from '@toghen/lib';
|
|
2
|
+
import type { TokenTypeConversionMap } from '../../shared/tokenTypeMap.js';
|
|
3
|
+
import { countSkipsByCause, type Operation, type SkipCounts, type StyleBinding, type SyncPlan } from '../../shared/operations.js';
|
|
4
|
+
import { getStamp, receiptOf, type Stamp } from '../components/stamp.js';
|
|
5
|
+
import { getIndexedVariableByCollectionAndName, indexFigmaCollections, type IndexedVariable } from '../utils/figmaCollectionIndex.js';
|
|
6
|
+
import { fontStack, numbersEqual } from '../utils/figmaTokenAdapter.js';
|
|
7
|
+
import { chooseFamily, fallbackNote, listFonts, noFamilyReason } from '../utils/fontStack.js';
|
|
8
|
+
import { isPlainObject } from '../utils/shardedData.js';
|
|
9
|
+
import { localStyles, reportOf, type StyleKind } from '../utils/tokenStamps.js';
|
|
10
|
+
import { toEffectStyleProps, toPaintStyleProps, toTextStyleProps } from '../utils/figmaStyleAdapter.js';
|
|
11
|
+
import { figmaCollectionNameFor, resolveSumModes, snapshotModes, type SnapshotToken } from '../utils/resolverModes.js';
|
|
12
|
+
import { closedBecause, standingOf } from '../utils/standing.js';
|
|
13
|
+
import { buildSum } from '../utils/sum.js';
|
|
14
|
+
import { referencePath } from '@toghen/lib';
|
|
15
|
+
import { tokenPathToVariableName } from '../utils/tokenPath.js';
|
|
16
|
+
|
|
17
|
+
export interface StylePlanSummary {
|
|
18
|
+
total: number;
|
|
19
|
+
creates: number;
|
|
20
|
+
updates: number;
|
|
21
|
+
skips: number;
|
|
22
|
+
/** `skips` by cause: their sum is `skips`. */
|
|
23
|
+
skipsByCause: SkipCounts;
|
|
24
|
+
/** Styles Figma holds something else in than the last sync wrote: a subset of `skips`, as for variables. */
|
|
25
|
+
drifted: number;
|
|
26
|
+
warnings: number;
|
|
27
|
+
[key: string]: number | SkipCounts;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StylePlanOptions {
|
|
31
|
+
/** Whether a style changed in Figma since the sync wrote it is written with the document's value, or left alone. */
|
|
32
|
+
overwriteDrift?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface IndexedStyle {
|
|
36
|
+
id: string;
|
|
37
|
+
style: BaseStyle;
|
|
38
|
+
stamp?: Stamp;
|
|
39
|
+
moved?: true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface StyleIndex {
|
|
43
|
+
/** By kind and the token its stamp names. A copy is not among them. */
|
|
44
|
+
byToken: Record<string, IndexedStyle>;
|
|
45
|
+
byNameAndType: Record<string, IndexedStyle>;
|
|
46
|
+
/** The copies a person made of a style Toghen wrote, each with the original and the token its stamp names. */
|
|
47
|
+
copies: Array<{ copy: BaseStyle; of: BaseStyle; token: string }>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function keyOf(styleType: StyleKind, name: string): string {
|
|
51
|
+
return `${styleType}::${name}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A style is named as a variable is: the token path without the collection, `/` between groups. */
|
|
55
|
+
function tokenPathToStyleName(path: string): string {
|
|
56
|
+
return tokenPathToVariableName(referencePath(path));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function indexStyles(): Promise<StyleIndex> {
|
|
60
|
+
const byToken: Record<string, IndexedStyle> = {};
|
|
61
|
+
const byNameAndType: Record<string, IndexedStyle> = {};
|
|
62
|
+
const copies: StyleIndex['copies'] = [];
|
|
63
|
+
for (const { style, styleType, stamp, moved, copyOf } of await localStyles()) {
|
|
64
|
+
const indexed: IndexedStyle = { id: style.id, style, ...(stamp ? { stamp } : {}), ...(moved ? { moved } : {}) };
|
|
65
|
+
byNameAndType[keyOf(styleType, style.name)] = indexed;
|
|
66
|
+
if (stamp?.is.token !== undefined) byToken[keyOf(styleType, stamp.is.token)] = indexed;
|
|
67
|
+
const token = copyOf ? getStamp(copyOf)?.is.token : undefined;
|
|
68
|
+
if (copyOf && token !== undefined) copies.push({ copy: style, of: copyOf, token });
|
|
69
|
+
}
|
|
70
|
+
return { byToken, byNameAndType, copies };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* What Figma would report for the fields a style plan writes, in `reportOf`'s shape: the props, and every field a
|
|
75
|
+
* binding can name bound to its variable or to nothing. Compared with what Figma reports by `holds`, it says whether
|
|
76
|
+
* the document asks for something else than the style holds.
|
|
77
|
+
*/
|
|
78
|
+
export function wantedOf(styleType: StyleKind, props: Record<string, unknown>, bindings: readonly StyleBinding[]): Record<string, unknown> {
|
|
79
|
+
const bound = (fields: Record<string, BindableField>, index?: number) => {
|
|
80
|
+
const aliases: Record<string, unknown> = {};
|
|
81
|
+
for (const { field } of Object.values(fields)) {
|
|
82
|
+
const binding = bindings.find((b) => b.field === field && (index === undefined || (b.index ?? 0) === index));
|
|
83
|
+
aliases[field] = binding ? { type: 'VARIABLE_ALIAS', id: binding.variableId } : undefined;
|
|
84
|
+
}
|
|
85
|
+
return aliases;
|
|
86
|
+
};
|
|
87
|
+
if (styleType === 'TEXT') {
|
|
88
|
+
const { fontFamily, fontStyle, ...rest } = props;
|
|
89
|
+
return { ...rest, fontName: { family: fontFamily, style: fontStyle }, boundVariables: bound(TEXT_FIELDS) };
|
|
90
|
+
}
|
|
91
|
+
if (styleType === 'EFFECT') {
|
|
92
|
+
return {
|
|
93
|
+
effects: (props.effects as Record<string, unknown>[]).map((effect, index) => ({
|
|
94
|
+
...effect,
|
|
95
|
+
boundVariables: bound(EFFECT_FIELDS, index),
|
|
96
|
+
})),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return props;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether what Figma reports holds every field `wanted` names: numbers within float32's tolerance, as a variable's are
|
|
104
|
+
* compared, lists item by item, and an object's other keys, which Figma adds (a paint's `visible`), left alone.
|
|
105
|
+
*/
|
|
106
|
+
export function holds(reported: unknown, wanted: unknown): boolean {
|
|
107
|
+
if (typeof wanted === 'number') return typeof reported === 'number' && numbersEqual(reported, wanted);
|
|
108
|
+
if (Array.isArray(wanted))
|
|
109
|
+
return Array.isArray(reported) && reported.length === wanted.length && wanted.every((item, index) => holds(reported[index], item));
|
|
110
|
+
if (isPlainObject(wanted)) {
|
|
111
|
+
const object = isPlainObject(reported) ? reported : {};
|
|
112
|
+
return Object.entries(wanted).every(([key, item]) => holds(object[key], item));
|
|
113
|
+
}
|
|
114
|
+
return reported === wanted;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildStyleProps(value: unknown, tokenType: string, styleType: 'TEXT' | 'EFFECT' | 'PAINT'): Record<string, unknown> {
|
|
118
|
+
switch (styleType) {
|
|
119
|
+
case 'TEXT':
|
|
120
|
+
return toTextStyleProps(value) as unknown as Record<string, unknown>;
|
|
121
|
+
case 'EFFECT':
|
|
122
|
+
return toEffectStyleProps(value) as unknown as Record<string, unknown>;
|
|
123
|
+
case 'PAINT':
|
|
124
|
+
return toPaintStyleProps(value) as unknown as Record<string, unknown>;
|
|
125
|
+
default:
|
|
126
|
+
throw new Error(`Unsupported style mapping ${styleType} for token type ${tokenType}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface BindableField {
|
|
131
|
+
field: string;
|
|
132
|
+
type: VariableResolvedDataType;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A typography member → the text style field of the same name. */
|
|
136
|
+
const TEXT_FIELDS: Record<string, BindableField & { field: VariableBindableTextField }> = {
|
|
137
|
+
fontFamily: { field: 'fontFamily', type: 'STRING' },
|
|
138
|
+
fontStyle: { field: 'fontStyle', type: 'STRING' },
|
|
139
|
+
fontWeight: { field: 'fontWeight', type: 'FLOAT' },
|
|
140
|
+
fontSize: { field: 'fontSize', type: 'FLOAT' },
|
|
141
|
+
letterSpacing: { field: 'letterSpacing', type: 'FLOAT' },
|
|
142
|
+
lineHeight: { field: 'lineHeight', type: 'FLOAT' },
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** A shadow layer's member → the effect field it binds to. `blur` is Figma's `radius`. */
|
|
146
|
+
const EFFECT_FIELDS: Record<string, BindableField & { field: VariableBindableEffectField }> = {
|
|
147
|
+
color: { field: 'color', type: 'COLOR' },
|
|
148
|
+
offsetX: { field: 'offsetX', type: 'FLOAT' },
|
|
149
|
+
offsetY: { field: 'offsetY', type: 'FLOAT' },
|
|
150
|
+
blur: { field: 'radius', type: 'FLOAT' },
|
|
151
|
+
spread: { field: 'spread', type: 'FLOAT' },
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Whether the variable's value means exactly what Figma reads in this field.
|
|
156
|
+
*
|
|
157
|
+
* A bound field reads the variable's number as the unit the field is already
|
|
158
|
+
* in, so a unitless `1.5` bound to a PERCENT line height would read as 1.5%. And
|
|
159
|
+
* a font stack is written to its variable as one joined string, which no font
|
|
160
|
+
* is called. Those members keep their value. This is not warned about: each is
|
|
161
|
+
* an ordinary way to author a token, and it would warn on every plan.
|
|
162
|
+
*/
|
|
163
|
+
function meansTheSame(member: string, _value: unknown, props: Record<string, unknown>): boolean {
|
|
164
|
+
switch (member) {
|
|
165
|
+
case 'lineHeight':
|
|
166
|
+
case 'letterSpacing':
|
|
167
|
+
return (props[member] as { unit?: string } | undefined)?.unit === 'PIXELS';
|
|
168
|
+
default:
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The bindings a style's members can carry, and why any cannot.
|
|
175
|
+
*
|
|
176
|
+
* A member binds when it is one whole reference (`memberOf`), its field is
|
|
177
|
+
* bindable, the unit rule holds, and the target's variable already exists with
|
|
178
|
+
* the field's type. The style phase runs after the variable phase and cannot
|
|
179
|
+
* create variables, so a missing one is a warning, not a plan step.
|
|
180
|
+
*/
|
|
181
|
+
function planBindings(
|
|
182
|
+
path: string,
|
|
183
|
+
token: SnapshotToken,
|
|
184
|
+
styleType: 'TEXT' | 'EFFECT' | 'PAINT',
|
|
185
|
+
props: Record<string, unknown>,
|
|
186
|
+
variableFor: (to: string) => IndexedVariable | undefined,
|
|
187
|
+
): { bindings: StyleBinding[]; warnings: string[] } {
|
|
188
|
+
const bindings: StyleBinding[] = [];
|
|
189
|
+
const warnings: string[] = [];
|
|
190
|
+
|
|
191
|
+
for (const [at, to] of Object.entries(token.memberOf ?? {})) {
|
|
192
|
+
let spec: BindableField | undefined;
|
|
193
|
+
let index: number | undefined;
|
|
194
|
+
|
|
195
|
+
if (styleType === 'TEXT') {
|
|
196
|
+
spec = TEXT_FIELDS[at];
|
|
197
|
+
if (spec && !meansTheSame(at, (token.value as Record<string, unknown>)[at], props)) continue;
|
|
198
|
+
} else if (styleType === 'EFFECT') {
|
|
199
|
+
// `'0.color'` in a list of layers, `'color'` in a single one.
|
|
200
|
+
const address = /^(?:(\d+)\.)?([^.]+)$/.exec(at);
|
|
201
|
+
spec = address ? EFFECT_FIELDS[address[2]] : undefined;
|
|
202
|
+
index = Number(address?.[1] ?? 0);
|
|
203
|
+
}
|
|
204
|
+
if (!spec) continue;
|
|
205
|
+
|
|
206
|
+
const variable = variableFor(to);
|
|
207
|
+
if (!variable) {
|
|
208
|
+
warnings.push(`"${path}.${at}" refers to "${to}", which has no variable yet; sync variables first, then styles, to bind it.`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (variable.resolvedType !== spec.type) {
|
|
212
|
+
warnings.push(
|
|
213
|
+
`"${path}.${at}" refers to "${to}", but that variable is ${variable.resolvedType} and the field ` +
|
|
214
|
+
`takes ${spec.type}, so the value is written instead of a binding.`,
|
|
215
|
+
);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
bindings.push({ field: spec.field, variableId: variable.id, ...(index !== undefined ? { index } : {}) });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { bindings, warnings };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export class StyleSyncEngine {
|
|
226
|
+
/**
|
|
227
|
+
* Plans the styles for the document's **default** mode.
|
|
228
|
+
*
|
|
229
|
+
* Figma styles genuinely have no modes, so one mode has to be chosen — but the
|
|
230
|
+
* choice used to be `getPermutations()[0]`, which follows declaration order
|
|
231
|
+
* and ignores `default`. A document declaring `contexts: {dark, light},
|
|
232
|
+
* default: 'light'` therefore had every one of its styles built from the dark
|
|
233
|
+
* theme, and said so in a warning naming "dark" as if that were intended.
|
|
234
|
+
*
|
|
235
|
+
* `collectionName` names the base collection, as in the variable plan; a
|
|
236
|
+
* member's variable is looked for in the collection its token lives in
|
|
237
|
+
* (`utils/sum.ts`), by its stamp, else by name.
|
|
238
|
+
*
|
|
239
|
+
* A style is found by its stamp, and by name only to adopt one: an adopted
|
|
240
|
+
* style is written whole, whatever its stamp says, since that receipt is not
|
|
241
|
+
* this token's. A stamped style whose fields Figma reports otherwise than the
|
|
242
|
+
* receipt was changed in Figma: drift, kept and said unless `overwriteDrift`.
|
|
243
|
+
*/
|
|
244
|
+
async buildPlan(
|
|
245
|
+
resolver: Resolver,
|
|
246
|
+
conversionMap: TokenTypeConversionMap,
|
|
247
|
+
collectionName: string = figmaCollectionNameFor(resolver),
|
|
248
|
+
options: StylePlanOptions = {},
|
|
249
|
+
): Promise<SyncPlan> {
|
|
250
|
+
const operations: Operation[] = [];
|
|
251
|
+
|
|
252
|
+
// The sum, as the variable plan reads it: the default mode is the defaults of every modifier.
|
|
253
|
+
const resolution = resolveSumModes(resolver);
|
|
254
|
+
const { defaultMode, modeNames } = resolution;
|
|
255
|
+
const snapshot = snapshotModes(resolution).find((s) => s.modeName === defaultMode);
|
|
256
|
+
if (!snapshot) {
|
|
257
|
+
return { operations };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (modeNames.length > 1) {
|
|
261
|
+
const others = modeNames.filter((name) => name !== defaultMode);
|
|
262
|
+
operations.push({
|
|
263
|
+
kind: 'WARNING',
|
|
264
|
+
message:
|
|
265
|
+
`Figma styles have no modes, so these are built from the document's default ` +
|
|
266
|
+
`mode "${defaultMode}". Values in ${others.join(', ')} are not represented; ` +
|
|
267
|
+
`use variables for anything that has to change per mode. A member bound to a ` +
|
|
268
|
+
`variable is the exception: it follows that variable's modes.`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const styleIndex = await indexStyles();
|
|
273
|
+
const figmaIndex = await indexFigmaCollections();
|
|
274
|
+
const sum = buildSum(resolver, collectionName, conversionMap);
|
|
275
|
+
const standing = standingOf(sum, figmaIndex);
|
|
276
|
+
// The head of the target's variables, as the variable plan links a token:
|
|
277
|
+
// its stamp first, then the name in the collection the variable lives in.
|
|
278
|
+
const variableFor = (to: string): IndexedVariable | undefined => {
|
|
279
|
+
const head = sum.tokenByPath.get(to)?.variables[0];
|
|
280
|
+
if (!head) return undefined;
|
|
281
|
+
const home = standing.get(head.collection);
|
|
282
|
+
// A collection nothing may be written to (see `closedBecause`) is not bound to either.
|
|
283
|
+
if (!home || closedBecause(home)) return undefined;
|
|
284
|
+
return (
|
|
285
|
+
figmaIndex.variablesByToken[head.key]?.find((stamped) => stamped.variableCollectionId === home.existing?.id) ??
|
|
286
|
+
(home.existing ? getIndexedVariableByCollectionAndName(figmaIndex, home.existing.id, head.name) : undefined)
|
|
287
|
+
);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// Read at the first text style, once: a plan without one never asks.
|
|
291
|
+
let listed: Awaited<ReturnType<typeof listFonts>> | undefined;
|
|
292
|
+
|
|
293
|
+
for (const [path, token] of snapshot.pathToToken) {
|
|
294
|
+
const route = conversionMap[token.type];
|
|
295
|
+
if (!route || route.target === 'skip') {
|
|
296
|
+
operations.push({
|
|
297
|
+
kind: 'SKIP',
|
|
298
|
+
reason: `"${path}": unsupported token type ${token.type || 'none'}`,
|
|
299
|
+
path,
|
|
300
|
+
cause: 'unsupported-type',
|
|
301
|
+
});
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// A variable's token is the variable plan's: this plan lists only the tokens it is for (ruling 11b).
|
|
306
|
+
if (route.target === 'variable') continue;
|
|
307
|
+
|
|
308
|
+
if (!route.styleType) {
|
|
309
|
+
operations.push({
|
|
310
|
+
kind: 'WARNING',
|
|
311
|
+
message: `Missing styleType route for token type ${token.type}`,
|
|
312
|
+
path,
|
|
313
|
+
});
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const styleName = tokenPathToStyleName(path);
|
|
318
|
+
|
|
319
|
+
// References nested inside a composite value — a typography token's
|
|
320
|
+
// fontFamily, a shadow's colour — are already expanded: @toghen/lib owns
|
|
321
|
+
// reference resolution, and the snapshot holds resolved values.
|
|
322
|
+
// A malformed value fails this one token, not the plan. The plugin does
|
|
323
|
+
// not register the core's validators, so this is where bad input is met.
|
|
324
|
+
let props: Record<string, unknown>;
|
|
325
|
+
try {
|
|
326
|
+
// The core's own refusal is the reason: it names the token, the mode and the member.
|
|
327
|
+
if (token.refused !== undefined) throw new Error(token.refused);
|
|
328
|
+
props = buildStyleProps(token.value, token.type, route.styleType);
|
|
329
|
+
} catch (err) {
|
|
330
|
+
// A skip naming the token, the mode the style is built from and the reason: the plan goes on.
|
|
331
|
+
operations.push({
|
|
332
|
+
kind: 'SKIP',
|
|
333
|
+
reason: `"${path}" cannot be built in ${defaultMode}: ${err instanceof Error ? err.message : String(err)}`,
|
|
334
|
+
path,
|
|
335
|
+
cause: 'cannot-build',
|
|
336
|
+
});
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// A stack is a fallback: the first family Figma lists with the style asked for. A later one is said, and
|
|
341
|
+
// leaves the family unbound, since the variable holds the first.
|
|
342
|
+
let fellBack = false;
|
|
343
|
+
const asked = { ...props };
|
|
344
|
+
if (route.styleType === 'TEXT') {
|
|
345
|
+
const stack = fontStack((token.value as Record<string, unknown>).fontFamily);
|
|
346
|
+
listed ??= await listFonts();
|
|
347
|
+
const choice = chooseFamily(stack, props.fontStyle as string, listed);
|
|
348
|
+
if (!choice) {
|
|
349
|
+
operations.push({ kind: 'SKIP', reason: noFamilyReason(path, stack), path, cause: 'no-font', reported: true });
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (choice.passedOver.length > 0) {
|
|
353
|
+
fellBack = true;
|
|
354
|
+
props.fontFamily = choice.family;
|
|
355
|
+
operations.push({ kind: 'WARNING', message: fallbackNote(path, choice), path });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const { bindings, warnings } = planBindings(path, token, route.styleType, props, variableFor);
|
|
360
|
+
for (const message of warnings) {
|
|
361
|
+
operations.push({ kind: 'WARNING', message, path });
|
|
362
|
+
}
|
|
363
|
+
const written = fellBack ? bindings.filter((binding) => binding.field !== 'fontFamily') : bindings;
|
|
364
|
+
|
|
365
|
+
const stamped = styleIndex.byToken[keyOf(route.styleType, path)];
|
|
366
|
+
const styleId = (stamped ?? styleIndex.byNameAndType[keyOf(route.styleType, styleName)])?.id;
|
|
367
|
+
if (stamped) {
|
|
368
|
+
const reported = reportOf(stamped.style);
|
|
369
|
+
const moved = Object.entries(receiptOf(reported))
|
|
370
|
+
.filter(([field, hash]) => stamped.stamp?.receipt[field] !== hash)
|
|
371
|
+
.map(([field]) => field);
|
|
372
|
+
// What this machine would write, or what one with the first family would: two machines with different
|
|
373
|
+
// fonts each leave the other's style alone.
|
|
374
|
+
const agrees =
|
|
375
|
+
holds(reported, wantedOf(route.styleType, props, written)) || holds(reported, wantedOf(route.styleType, asked, bindings));
|
|
376
|
+
const skips = (agrees && moved.length === 0) || (!agrees && moved.length > 0 && !options.overwriteDrift);
|
|
377
|
+
// A style that moved records its own id on its next write; one the plan does not write is stamped on its own.
|
|
378
|
+
if (skips && stamped.moved) operations.push({ kind: 'STAMP', target: 'style', id: stamped.id, path });
|
|
379
|
+
if (agrees && moved.length === 0) {
|
|
380
|
+
operations.push({ kind: 'SKIP', reason: 'unchanged', path, cause: 'unchanged' });
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (!agrees && moved.length > 0 && !options.overwriteDrift) {
|
|
384
|
+
operations.push({
|
|
385
|
+
kind: 'SKIP',
|
|
386
|
+
reason: 'drifted',
|
|
387
|
+
cause: 'drifted',
|
|
388
|
+
message:
|
|
389
|
+
`Changed in Figma since the last sync (${moved.join(', ')}). Figma no longer holds what was written, so this is a ` +
|
|
390
|
+
`local edit — it is left alone rather than overwritten; pass --overwrite-drift to write the document's value.`,
|
|
391
|
+
path,
|
|
392
|
+
});
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (styleId) {
|
|
398
|
+
operations.push({
|
|
399
|
+
kind: 'UPDATE_STYLE',
|
|
400
|
+
styleId,
|
|
401
|
+
props,
|
|
402
|
+
tokenPath: path,
|
|
403
|
+
...(written.length > 0 ? { bindings: written } : {}),
|
|
404
|
+
});
|
|
405
|
+
} else {
|
|
406
|
+
operations.push({
|
|
407
|
+
kind: 'CREATE_STYLE',
|
|
408
|
+
styleType: route.styleType,
|
|
409
|
+
name: styleName,
|
|
410
|
+
props,
|
|
411
|
+
tokenPath: path,
|
|
412
|
+
...(written.length > 0 ? { bindings: written } : {}),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// A person's copy of a style this document's sync wrote is left alone, and listed.
|
|
418
|
+
const document = `${sum.dsName}.`;
|
|
419
|
+
for (const { copy, of, token } of styleIndex.copies) {
|
|
420
|
+
if (token.startsWith(document))
|
|
421
|
+
operations.push({
|
|
422
|
+
kind: 'SKIP',
|
|
423
|
+
reason: `"${copy.name}" (${copy.id}) is a copy of Toghen's "${of.name}", left alone`,
|
|
424
|
+
path: token,
|
|
425
|
+
cause: 'copy',
|
|
426
|
+
reported: true,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return { operations };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
summarizePlan(operations: Operation[]): StylePlanSummary {
|
|
434
|
+
return {
|
|
435
|
+
total: operations.length,
|
|
436
|
+
creates: operations.filter((op) => op.kind === 'CREATE_STYLE').length,
|
|
437
|
+
updates: operations.filter((op) => op.kind === 'UPDATE_STYLE' || op.kind === 'STAMP').length,
|
|
438
|
+
skips: operations.filter((op) => op.kind === 'SKIP').length,
|
|
439
|
+
skipsByCause: countSkipsByCause(operations),
|
|
440
|
+
drifted: operations.filter((op) => op.kind === 'SKIP' && op.reason === 'drifted').length,
|
|
441
|
+
warnings: operations.filter((op) => op.kind === 'WARNING').length,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
}
|