@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,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FigCraft's file in Figma's words, and Figma's operations in FigCraft's: the two translations the FigCraft target needs.
|
|
3
|
+
*
|
|
4
|
+
* FigCraft's variables are Figma's model, spelled its own way: `variable.list` answers collections with mode ids, values
|
|
5
|
+
* per mode id, aliases, scopes and code syntax, with types in lower case; `style.list` answers each style's value, in
|
|
6
|
+
* snake case. So the plans read FigCraft through the same shapes they read Figma through, a style's fields included,
|
|
7
|
+
* which its stamp's receipt hashes, and each plan operation becomes one or a few FigCraft commands. Nothing here talks to
|
|
8
|
+
* FigCraft; `figcraft.ts` does, and this module is tested without it (specs.md, *The command line*, *FigCraft*).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Operation, StyleBinding } from '../shared/operations.js';
|
|
12
|
+
|
|
13
|
+
/** A FigCraft command, as `engine.execute` and `command.batch` take it. `{"$step": n}` in params stands for step n's `id`. */
|
|
14
|
+
export interface Step {
|
|
15
|
+
command: string;
|
|
16
|
+
params: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FcCollection {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
modes: { id: string; name: string }[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FcVariable {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
collection: string;
|
|
29
|
+
resolvedType: 'color' | 'number' | 'string' | 'boolean';
|
|
30
|
+
values: Record<string, { type: string; value: unknown }>;
|
|
31
|
+
scopes?: string[];
|
|
32
|
+
codeSyntax?: { WEB?: string };
|
|
33
|
+
description?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FcStyle {
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
type: 'text' | 'effect' | 'paint' | 'grid';
|
|
40
|
+
/** The style's fields that follow a variable, by FigCraft's field name: `fontSize`, `effects/0/radius`. */
|
|
41
|
+
bound?: Record<string, string>;
|
|
42
|
+
/** A text style's value. A bound field lists the variable's value. */
|
|
43
|
+
style?: FcText;
|
|
44
|
+
effects?: FcEffect[];
|
|
45
|
+
paints?: FcPaint[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A length as `style.list` gives one. */
|
|
49
|
+
interface FcLength {
|
|
50
|
+
unit: 'pixels' | 'percent' | 'auto';
|
|
51
|
+
value?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface FcText {
|
|
55
|
+
font_family: string;
|
|
56
|
+
font_style: string;
|
|
57
|
+
font_size: number;
|
|
58
|
+
line_height: FcLength;
|
|
59
|
+
letter_spacing: FcLength;
|
|
60
|
+
case: string;
|
|
61
|
+
decoration: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface FcEffect {
|
|
65
|
+
type: string;
|
|
66
|
+
color: RGBA;
|
|
67
|
+
offset: [number, number];
|
|
68
|
+
radius: number;
|
|
69
|
+
spread: number;
|
|
70
|
+
visible: boolean;
|
|
71
|
+
blend: string;
|
|
72
|
+
show_behind?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface FcPaint {
|
|
76
|
+
type: string;
|
|
77
|
+
kind?: string;
|
|
78
|
+
stops?: { color: RGBA; position: number }[];
|
|
79
|
+
from?: [number, number];
|
|
80
|
+
to?: [number, number];
|
|
81
|
+
opacity?: number;
|
|
82
|
+
visible?: boolean;
|
|
83
|
+
blend?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface FcListing {
|
|
87
|
+
collections: FcCollection[];
|
|
88
|
+
variables: FcVariable[];
|
|
89
|
+
styles: FcStyle[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const FIGMA_TYPE = { color: 'COLOR', number: 'FLOAT', string: 'STRING', boolean: 'BOOLEAN' } as const;
|
|
93
|
+
const FC_TYPE: Partial<Record<VariableResolvedDataType, FcVariable['resolvedType']>> = {
|
|
94
|
+
COLOR: 'color',
|
|
95
|
+
FLOAT: 'number',
|
|
96
|
+
STRING: 'string',
|
|
97
|
+
BOOLEAN: 'boolean',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** The file as the plans read Figma: collections, variables and local styles, with only the fields they read. */
|
|
101
|
+
export interface FigmaView {
|
|
102
|
+
collections: Pick<VariableCollection, 'id' | 'name' | 'modes' | 'defaultModeId' | 'variableIds'>[];
|
|
103
|
+
variables: Pick<
|
|
104
|
+
Variable,
|
|
105
|
+
'id' | 'name' | 'variableCollectionId' | 'resolvedType' | 'valuesByMode' | 'scopes' | 'codeSyntax' | 'description'
|
|
106
|
+
>[];
|
|
107
|
+
/** A style with the fields `reportOf` reads, as Figma reports them: what its stamp's receipt hashes. */
|
|
108
|
+
styles: ({ id: string; name: string; type: 'TEXT' | 'EFFECT' | 'PAINT' | 'GRID' } & Record<string, unknown>)[];
|
|
109
|
+
/**
|
|
110
|
+
* A library's copies in the file, as Figma gives an imported variable or style: `remote`, keyed by their id, which is
|
|
111
|
+
* `<library id>:<asset id>`. Found by id, never listed as the file's own.
|
|
112
|
+
*/
|
|
113
|
+
copies?: Pick<FigmaView, 'collections' | 'variables' | 'styles'>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A library's copy, which `variable.list` and `style.list` list beside the file's own once a layer uses a library's
|
|
118
|
+
* variable or style: its id carries the library's id (`294b4f2d88c18a6e:VariableID:1`, `294b4f2d88c18a6e:S:1`), a
|
|
119
|
+
* copied collection's mode ids do not (measured 23 October 2026).
|
|
120
|
+
*/
|
|
121
|
+
export const fromLibrary = (id: string): boolean => /^[0-9a-f]{16}:/.test(id);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The file as the plans read it: its own collections, variables and styles, as Figma's `getLocal…` lists them, never a
|
|
125
|
+
* library's; and apart, a library's copies, as Figma's imported ones.
|
|
126
|
+
*/
|
|
127
|
+
export function figmaView(all: FcListing): FigmaView {
|
|
128
|
+
const part = (library: boolean): FcListing => ({
|
|
129
|
+
collections: all.collections.filter((c) => fromLibrary(c.id) === library),
|
|
130
|
+
variables: all.variables.filter((v) => fromLibrary(v.id) === library),
|
|
131
|
+
styles: all.styles.filter((style) => fromLibrary(style.id) === library),
|
|
132
|
+
});
|
|
133
|
+
const copies = shaped(part(true));
|
|
134
|
+
const remote = <T extends { id: string }>(object: T): T => Object.assign(object, { remote: true, key: object.id });
|
|
135
|
+
return {
|
|
136
|
+
...shaped(part(false)),
|
|
137
|
+
copies: { collections: copies.collections.map(remote), variables: copies.variables.map(remote), styles: copies.styles.map(remote) },
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A listing in Figma's shapes. */
|
|
142
|
+
function shaped(listing: FcListing): Omit<FigmaView, 'copies'> {
|
|
143
|
+
const collections = listing.collections.map((collection) => ({
|
|
144
|
+
id: collection.id,
|
|
145
|
+
name: collection.name,
|
|
146
|
+
modes: collection.modes.map((mode) => ({ modeId: mode.id, name: mode.name })),
|
|
147
|
+
defaultModeId: collection.modes[0]?.id ?? '',
|
|
148
|
+
variableIds: listing.variables.filter((variable) => variable.collection === collection.id).map((variable) => variable.id),
|
|
149
|
+
}));
|
|
150
|
+
const variables = listing.variables.map((variable) => ({
|
|
151
|
+
id: variable.id,
|
|
152
|
+
name: variable.name,
|
|
153
|
+
variableCollectionId: variable.collection,
|
|
154
|
+
resolvedType: FIGMA_TYPE[variable.resolvedType],
|
|
155
|
+
valuesByMode: Object.fromEntries(
|
|
156
|
+
Object.entries(variable.values).map(([mode, held]): [string, VariableValue] => [
|
|
157
|
+
mode,
|
|
158
|
+
held.type === 'alias' ? { type: 'VARIABLE_ALIAS', id: String(held.value) } : (held.value as VariableValue),
|
|
159
|
+
]),
|
|
160
|
+
),
|
|
161
|
+
// A variable FigCraft lists without scopes is offered to every field, as Figma's default is.
|
|
162
|
+
scopes: (variable.scopes ?? ['ALL_SCOPES']) as VariableScope[],
|
|
163
|
+
codeSyntax: variable.codeSyntax?.WEB !== undefined ? { WEB: variable.codeSyntax.WEB } : {},
|
|
164
|
+
description: variable.description ?? '',
|
|
165
|
+
}));
|
|
166
|
+
const styles = listing.styles.map((style) => ({
|
|
167
|
+
id: style.id,
|
|
168
|
+
name: style.name,
|
|
169
|
+
type: style.type.toUpperCase() as FigmaView['styles'][number]['type'],
|
|
170
|
+
...reported(style),
|
|
171
|
+
}));
|
|
172
|
+
return { collections, variables, styles };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** FigCraft's camel case as Figma's upper snake case: `dropShadow` is `DROP_SHADOW`, `smallCaps` is `SMALL_CAPS`. */
|
|
176
|
+
const figmaWord = (word: string): string => word.replace(/[A-Z]/g, (letter) => `_${letter}`).toUpperCase();
|
|
177
|
+
|
|
178
|
+
const aliasOf = (id: string): VariableAlias => ({ type: 'VARIABLE_ALIAS', id });
|
|
179
|
+
|
|
180
|
+
/** A length as Figma reports one. */
|
|
181
|
+
function lengthOf(held: FcLength): { unit: 'AUTO' } | { unit: 'PIXELS' | 'PERCENT'; value: number } {
|
|
182
|
+
return held.unit === 'auto' ? { unit: 'AUTO' } : { unit: held.unit === 'percent' ? 'PERCENT' : 'PIXELS', value: held.value ?? 0 };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Figma's gradient transform from the ramp's ends. Toghen writes a rotation about the layer's centre and nothing else
|
|
187
|
+
* (`angleToLinearTransform`), so the gradient's square is taken as a square: its second side is its first turned a
|
|
188
|
+
* quarter. The inverse of `throughInverse`, for the two points `paintsValue` writes.
|
|
189
|
+
*/
|
|
190
|
+
export function transformOf(paint: FcPaint): Transform {
|
|
191
|
+
const [fx, fy] = paint.from ?? [0, 0.5];
|
|
192
|
+
const [tx, ty] = paint.to ?? [1, 0.5];
|
|
193
|
+
// The square's sides in the layer: a linear ramp runs from its left middle, the others from its centre.
|
|
194
|
+
const linear = paint.kind === 'linear';
|
|
195
|
+
const [a, b] = linear ? [tx - fx, ty - fy] : [2 * (tx - fx), 2 * (ty - fy)];
|
|
196
|
+
const [c, d] = [-b, a];
|
|
197
|
+
const [ox, oy] = linear ? [fx - 0.5 * c, fy - 0.5 * d] : [fx - 0.5 * (a + c), fy - 0.5 * (b + d)];
|
|
198
|
+
const det = a * d - b * c;
|
|
199
|
+
return [
|
|
200
|
+
[d / det, -c / det, (c * oy - d * ox) / det],
|
|
201
|
+
[-b / det, a / det, (b * ox - a * oy) / det],
|
|
202
|
+
];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The fields `reportOf` reads, from what `style.list` gives: a text style's value, an effect style's shadows, a paint style's gradients. */
|
|
206
|
+
function reported(style: FcStyle): Record<string, unknown> {
|
|
207
|
+
const bound = Object.entries(style.bound ?? {});
|
|
208
|
+
if (style.style) {
|
|
209
|
+
const text = style.style;
|
|
210
|
+
return {
|
|
211
|
+
fontName: { family: text.font_family, style: text.font_style },
|
|
212
|
+
fontSize: text.font_size,
|
|
213
|
+
lineHeight: lengthOf(text.line_height),
|
|
214
|
+
letterSpacing: lengthOf(text.letter_spacing),
|
|
215
|
+
textCase: figmaWord(text.case),
|
|
216
|
+
textDecoration: figmaWord(text.decoration),
|
|
217
|
+
boundVariables: Object.fromEntries(bound.filter(([field]) => !field.includes('/')).map(([field, id]) => [field, aliasOf(id)])),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (style.effects) {
|
|
221
|
+
return {
|
|
222
|
+
effects: style.effects.map((effect, index) => ({
|
|
223
|
+
type: figmaWord(effect.type),
|
|
224
|
+
color: effect.color,
|
|
225
|
+
offset: { x: effect.offset[0], y: effect.offset[1] },
|
|
226
|
+
radius: effect.radius,
|
|
227
|
+
spread: effect.spread,
|
|
228
|
+
visible: effect.visible,
|
|
229
|
+
blendMode: figmaWord(effect.blend),
|
|
230
|
+
...(effect.show_behind !== undefined ? { showShadowBehindNode: effect.show_behind } : {}),
|
|
231
|
+
boundVariables: Object.fromEntries(
|
|
232
|
+
bound
|
|
233
|
+
.filter(([field]) => field.startsWith(`effects/${index}/`))
|
|
234
|
+
.map(([field, id]) => [field.slice(`effects/${index}/`.length), aliasOf(id)]),
|
|
235
|
+
),
|
|
236
|
+
})),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (style.paints) {
|
|
240
|
+
return {
|
|
241
|
+
paints: style.paints.map((paint) =>
|
|
242
|
+
paint.type === 'gradient' && paint.kind
|
|
243
|
+
? {
|
|
244
|
+
type: `GRADIENT_${figmaWord(paint.kind)}`,
|
|
245
|
+
gradientStops: (paint.stops ?? []).map((stop) => ({ position: stop.position, color: stop.color })),
|
|
246
|
+
gradientTransform: transformOf(paint),
|
|
247
|
+
opacity: paint.opacity ?? 1,
|
|
248
|
+
visible: paint.visible ?? true,
|
|
249
|
+
blendMode: figmaWord(paint.blend ?? 'normal'),
|
|
250
|
+
}
|
|
251
|
+
: { type: figmaWord(paint.type) },
|
|
252
|
+
),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
return {};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** What an operation becomes in FigCraft: its steps, run as one batch, and the id the run records for it. */
|
|
259
|
+
export interface Translation {
|
|
260
|
+
steps: Step[];
|
|
261
|
+
/** The id of what the operation made or changed, from the steps' results. */
|
|
262
|
+
idOf(results: unknown[]): string | undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const idAt =
|
|
266
|
+
(index: number) =>
|
|
267
|
+
(results: unknown[]): string | undefined => {
|
|
268
|
+
const id = (results[index] as { id?: unknown } | undefined)?.id;
|
|
269
|
+
return id === undefined ? undefined : String(id);
|
|
270
|
+
};
|
|
271
|
+
const fixed = (id: string | undefined) => (): string | undefined => id;
|
|
272
|
+
|
|
273
|
+
/** A value as `variable.set` takes it: a colour as `{r, g, b, a}` in 0–1, as Figma holds it, and an alias as `{alias}`. */
|
|
274
|
+
function valueFor(value: VariableValue): unknown {
|
|
275
|
+
if (typeof value === 'object' && value !== null && 'type' in value && value.type === 'VARIABLE_ALIAS') return { alias: value.id };
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** A length as FigCraft's text fields take one: a number in pixels, or a percentage. */
|
|
280
|
+
function length(value: unknown): unknown {
|
|
281
|
+
if (typeof value !== 'object' || value === null) return value;
|
|
282
|
+
const { unit, value: amount } = value as { unit?: string; value?: number };
|
|
283
|
+
if (unit === 'AUTO') return 'auto';
|
|
284
|
+
if (unit === 'PERCENT') return `${amount}%`;
|
|
285
|
+
return amount;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Figma's text style properties in FigCraft's spelling. Case and decoration are `case` and `decoration` in a style's value. */
|
|
289
|
+
function textValue(props: Record<string, unknown>): Record<string, unknown> {
|
|
290
|
+
const value: Record<string, unknown> = {};
|
|
291
|
+
if (typeof props.fontFamily === 'string') value.fontFamily = props.fontFamily;
|
|
292
|
+
if (typeof props.fontStyle === 'string') value.fontStyle = props.fontStyle;
|
|
293
|
+
if (typeof props.fontSize === 'number') value.fontSize = props.fontSize;
|
|
294
|
+
if (props.lineHeight !== undefined) value.lineHeight = length(props.lineHeight);
|
|
295
|
+
if (props.letterSpacing !== undefined) value.letterSpacing = length(props.letterSpacing);
|
|
296
|
+
if (typeof props.textCase === 'string') value.case = props.textCase === 'SMALL_CAPS' ? 'smallCaps' : props.textCase.toLowerCase();
|
|
297
|
+
if (typeof props.textDecoration === 'string') value.decoration = props.textDecoration.toLowerCase();
|
|
298
|
+
return value;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const EFFECT_TYPE: Record<string, string> = {
|
|
302
|
+
DROP_SHADOW: 'dropShadow',
|
|
303
|
+
INNER_SHADOW: 'innerShadow',
|
|
304
|
+
LAYER_BLUR: 'layerBlur',
|
|
305
|
+
BACKGROUND_BLUR: 'backgroundBlur',
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
/** Figma's effects in FigCraft's spelling: a shadow whole, a layer blur by its radius. */
|
|
309
|
+
export function effectsValue(props: Record<string, unknown>): unknown[] {
|
|
310
|
+
return ((props.effects as Effect[] | undefined) ?? []).map((effect) => {
|
|
311
|
+
if (effect.type === 'LAYER_BLUR') return { type: 'layerBlur', radius: effect.radius, visible: effect.visible };
|
|
312
|
+
if (effect.type !== 'DROP_SHADOW' && effect.type !== 'INNER_SHADOW')
|
|
313
|
+
throw new Error(`FigCraft target: an effect of ${effect.type} is not translated.`);
|
|
314
|
+
return {
|
|
315
|
+
type: EFFECT_TYPE[effect.type],
|
|
316
|
+
color: effect.color,
|
|
317
|
+
offset: [effect.offset.x, effect.offset.y],
|
|
318
|
+
radius: effect.radius,
|
|
319
|
+
spread: effect.spread ?? 0,
|
|
320
|
+
visible: effect.visible,
|
|
321
|
+
blend: 'normal',
|
|
322
|
+
};
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const GRADIENT_KIND: Record<string, string> = {
|
|
327
|
+
GRADIENT_LINEAR: 'linear',
|
|
328
|
+
GRADIENT_RADIAL: 'radial',
|
|
329
|
+
GRADIENT_ANGULAR: 'angular',
|
|
330
|
+
GRADIENT_DIAMOND: 'diamond',
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Where a layer-space point lands under the inverse of Figma's gradient transform. Figma's transform takes the layer's
|
|
335
|
+
* unit square to the gradient's, whose ramp runs from (0, ½) to (1, ½); FigCraft takes the ramp's ends in the layer.
|
|
336
|
+
*/
|
|
337
|
+
function throughInverse(transform: Transform, x: number, y: number): [number, number] {
|
|
338
|
+
const [[a, b, c], [d, e, f]] = transform;
|
|
339
|
+
const det = a * e - b * d;
|
|
340
|
+
const px = x - c;
|
|
341
|
+
const py = y - f;
|
|
342
|
+
return [(e * px - b * py) / det, (a * py - d * px) / det];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Figma's gradients in FigCraft's spelling: a style's paints, and a layer's, which `node.set` takes alike (measured 23 October 2026). */
|
|
346
|
+
export function paintsValue(props: Record<string, unknown>): unknown[] {
|
|
347
|
+
return ((props.paints as Paint[] | undefined) ?? []).map((paint) => {
|
|
348
|
+
const kind = GRADIENT_KIND[paint.type];
|
|
349
|
+
if (!kind || !('gradientStops' in paint)) throw new Error(`FigCraft target: a paint style of ${paint.type} is not translated.`);
|
|
350
|
+
// A linear ramp runs along the middle of the gradient's square; the others start at its centre.
|
|
351
|
+
const start = kind === 'linear' ? throughInverse(paint.gradientTransform, 0, 0.5) : throughInverse(paint.gradientTransform, 0.5, 0.5);
|
|
352
|
+
return {
|
|
353
|
+
type: 'gradient',
|
|
354
|
+
kind,
|
|
355
|
+
stops: paint.gradientStops.map((stop) => ({ color: stop.color, position: stop.position })),
|
|
356
|
+
from: start,
|
|
357
|
+
to: throughInverse(paint.gradientTransform, 1, 0.5),
|
|
358
|
+
opacity: paint.opacity ?? 1,
|
|
359
|
+
visible: paint.visible ?? true,
|
|
360
|
+
blend: 'normal',
|
|
361
|
+
};
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* A style binding's field in FigCraft's words, or nothing when FigCraft binds no such field. FigCraft binds a text
|
|
367
|
+
* style's family and style, not its weight; a weight stays written into the style's font style, as an unbound value.
|
|
368
|
+
*/
|
|
369
|
+
function boundField(styleType: 'TEXT' | 'EFFECT' | 'PAINT', binding: StyleBinding): string | undefined {
|
|
370
|
+
if (styleType === 'TEXT') return binding.field === 'fontWeight' ? undefined : binding.field;
|
|
371
|
+
if (styleType === 'EFFECT') return `effects/${binding.index ?? 0}/${binding.field}`;
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function styleValue(styleType: 'TEXT' | 'EFFECT' | 'PAINT', props: Record<string, unknown>): unknown {
|
|
376
|
+
if (styleType === 'TEXT') return textValue(props);
|
|
377
|
+
if (styleType === 'EFFECT') return effectsValue(props);
|
|
378
|
+
return paintsValue(props);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const binds = (style: unknown, styleType: 'TEXT' | 'EFFECT' | 'PAINT', bindings: readonly StyleBinding[] = []): Step[] =>
|
|
382
|
+
bindings.flatMap((binding) => {
|
|
383
|
+
const field = boundField(styleType, binding);
|
|
384
|
+
return field === undefined ? [] : [{ command: 'style.bind', params: { style, field, variable: binding.variableId } }];
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* One operation as FigCraft commands. `held` is the style an `UPDATE_STYLE` changes, as FigCraft lists it: its
|
|
389
|
+
* bound fields are let go before the new value is written, since a field that stays bound ignores the value.
|
|
390
|
+
*/
|
|
391
|
+
export function translate(op: Operation, held?: FcStyle): Translation {
|
|
392
|
+
switch (op.kind) {
|
|
393
|
+
case 'CREATE_COLLECTION':
|
|
394
|
+
if (op.modes.length === 0) throw new Error(`CREATE_COLLECTION requires at least one mode: ${op.name}`);
|
|
395
|
+
return { steps: [{ command: 'variable.createCollection', params: { name: op.name, modes: op.modes } }], idOf: idAt(0) };
|
|
396
|
+
case 'ADD_MODE':
|
|
397
|
+
return {
|
|
398
|
+
steps: [{ command: 'variable.addMode', params: { collection: op.collectionId, name: op.modeName } }],
|
|
399
|
+
idOf: fixed(op.collectionId),
|
|
400
|
+
};
|
|
401
|
+
case 'DELETE_MODE':
|
|
402
|
+
return {
|
|
403
|
+
steps: [{ command: 'variable.removeMode', params: { collection: op.collectionId, mode: op.modeId } }],
|
|
404
|
+
idOf: fixed(op.collectionId),
|
|
405
|
+
};
|
|
406
|
+
case 'DELETE_VARIABLE':
|
|
407
|
+
return { steps: [{ command: 'variable.delete', params: { variable: op.variableId } }], idOf: fixed(op.variableId) };
|
|
408
|
+
case 'CREATE_VARIABLE':
|
|
409
|
+
if (!FC_TYPE[op.variableType]) throw new Error(`FigCraft has no ${op.variableType} variables.`);
|
|
410
|
+
return {
|
|
411
|
+
steps: [
|
|
412
|
+
{ command: 'variable.create', params: { collection: op.collectionId, name: op.path, type: FC_TYPE[op.variableType] } },
|
|
413
|
+
...(op.scopes ? [{ command: 'variable.update', params: { variable: { $step: 0 }, scopes: op.scopes } }] : []),
|
|
414
|
+
],
|
|
415
|
+
idOf: idAt(0),
|
|
416
|
+
};
|
|
417
|
+
case 'SET_VARIABLE_SCOPES':
|
|
418
|
+
return {
|
|
419
|
+
steps: [{ command: 'variable.update', params: { variable: op.variableId, scopes: op.scopes } }],
|
|
420
|
+
idOf: fixed(op.variableId),
|
|
421
|
+
};
|
|
422
|
+
case 'SET_VARIABLE_VALUE':
|
|
423
|
+
return {
|
|
424
|
+
steps: [{ command: 'variable.set', params: { variable: op.variableId, mode: op.modeId, value: valueFor(op.value) } }],
|
|
425
|
+
idOf: fixed(op.variableId),
|
|
426
|
+
};
|
|
427
|
+
case 'RENAME_VARIABLE':
|
|
428
|
+
return { steps: [{ command: 'variable.update', params: { variable: op.variableId, name: op.name } }], idOf: fixed(op.variableId) };
|
|
429
|
+
case 'SET_CODE_SYNTAX':
|
|
430
|
+
return {
|
|
431
|
+
steps: [{ command: 'variable.update', params: { variable: op.variableId, codeSyntax: { [op.platform]: op.value } } }],
|
|
432
|
+
idOf: fixed(op.variableId),
|
|
433
|
+
};
|
|
434
|
+
case 'CREATE_STYLE':
|
|
435
|
+
return {
|
|
436
|
+
steps: [
|
|
437
|
+
{
|
|
438
|
+
command: 'style.create',
|
|
439
|
+
params: { type: op.styleType.toLowerCase(), name: op.name, value: styleValue(op.styleType, op.props) },
|
|
440
|
+
},
|
|
441
|
+
...binds({ $step: 0 }, op.styleType, op.bindings),
|
|
442
|
+
],
|
|
443
|
+
idOf: idAt(0),
|
|
444
|
+
};
|
|
445
|
+
case 'UPDATE_STYLE': {
|
|
446
|
+
if (!held) throw new Error(`Style not found: ${op.styleId}`);
|
|
447
|
+
const styleType = held.type.toUpperCase() as 'TEXT' | 'EFFECT' | 'PAINT';
|
|
448
|
+
if (styleType !== 'TEXT' && styleType !== 'EFFECT' && styleType !== 'PAINT') throw new Error(`Unsupported style type: ${held.type}`);
|
|
449
|
+
return {
|
|
450
|
+
steps: [
|
|
451
|
+
...Object.keys(held.bound ?? {}).map((field) => ({ command: 'style.bind', params: { style: op.styleId, field } })),
|
|
452
|
+
{ command: 'style.update', params: { style: op.styleId, value: styleValue(styleType, op.props) } },
|
|
453
|
+
...binds(op.styleId, styleType, op.bindings),
|
|
454
|
+
],
|
|
455
|
+
idOf: fixed(op.styleId),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
case 'BIND_VARIABLE':
|
|
459
|
+
case 'UNBIND_VARIABLE':
|
|
460
|
+
case 'APPLY_TOKEN':
|
|
461
|
+
throw new Error('Binding layers is not built for FigCraft yet; the FigCraft target syncs variables and styles.');
|
|
462
|
+
// Nothing to write in the file: the stamp goes into the record (`figcraftRecord.ts`).
|
|
463
|
+
case 'STAMP':
|
|
464
|
+
return { steps: [], idOf: fixed(op.id) };
|
|
465
|
+
case 'SKIP':
|
|
466
|
+
case 'WARNING':
|
|
467
|
+
case 'BREAKING':
|
|
468
|
+
case 'NOTE':
|
|
469
|
+
case 'SWAP_COMPONENT':
|
|
470
|
+
throw new Error(`No executor for operation kind: ${op.kind}`);
|
|
471
|
+
default: {
|
|
472
|
+
const _exhaustive: never = op;
|
|
473
|
+
throw new Error(`Unhandled operation kind: ${String(_exhaustive)}`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A library in FigCraft: a `.figcraft` file published as a library, enabled in another file by its path (FigCraft
|
|
3
|
+
* 0.10.0; .reference/plans/toghen-figma/plan-2026-10-23-figcraft-libraries.md, 1.1, and specs.md, *FigCraft*, *Libraries*).
|
|
4
|
+
*
|
|
5
|
+
* FigCraft names a library by the id its file keeps, so a moved file is the same library; a run reaches it by its
|
|
6
|
+
* path. An asset is the library's own id for it: a component's or set's node id in the library file, `VariableID:n`,
|
|
7
|
+
* `S:n`. Toghen's key for it is `<library id>:<asset id>`, which for a variable or a style is also its copy's id in a
|
|
8
|
+
* file that uses it. The using file's listing of the library (`library.list {assets}`) names every component, style
|
|
9
|
+
* and variable, so the lock is filled from it: no second plugin, and nothing for a person to pick.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { basename } from 'node:path';
|
|
13
|
+
import type { LockedLibrary } from '../shared/protocol.js';
|
|
14
|
+
import { inFile, savePinned, type Control, type Pin } from './figcraft.js';
|
|
15
|
+
|
|
16
|
+
/** A library as `library.list {assets: true}` lists it in a file that enabled it. */
|
|
17
|
+
export interface FcLibrary {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
path?: string;
|
|
21
|
+
version?: number;
|
|
22
|
+
error?: string;
|
|
23
|
+
updates?: number;
|
|
24
|
+
components?: Array<{ id: number | string; name: string; set: boolean }>;
|
|
25
|
+
styles?: Array<{ id: string; name: string; type: string }>;
|
|
26
|
+
collections?: Array<{ id: string; name: string }>;
|
|
27
|
+
variables?: Array<{ id: string; name: string; collection: string }>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The lock's name for a library: its file's name without `.figcraft`, which a person and a blueprint can write. */
|
|
31
|
+
export const libraryName = (path: string): string => basename(path).replace(/\.figcraft$/i, '');
|
|
32
|
+
|
|
33
|
+
/** A share link names a team library on a `figcraft-server`: outside this machine, and not built. */
|
|
34
|
+
export const isShareLink = (path: string): boolean => /^[a-z][a-z0-9+.-]*:\/\//i.test(path);
|
|
35
|
+
|
|
36
|
+
/** A FigCraft key: `<library id>:<asset id>`. */
|
|
37
|
+
export const keyOf = (library: string, asset: number | string): string => `${library}:${asset}`;
|
|
38
|
+
|
|
39
|
+
/** A key in FigCraft's form, split; a Figma key (forty hex digits) is not one. */
|
|
40
|
+
export function splitKey(key: string): { library: string; asset: string } | undefined {
|
|
41
|
+
const match = /^([0-9a-f]{16}):(.+)$/.exec(key);
|
|
42
|
+
return match ? { library: match[1], asset: match[2] } : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const KINDS: Record<string, 'TEXT' | 'EFFECT' | 'PAINT'> = { text: 'TEXT', effect: 'EFFECT', paint: 'PAINT' };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The lock's entry for a library, from the using file's listing of it: each component and set by its name, each text,
|
|
49
|
+
* effect and paint style by the token path its name spells (`/` to `.`, as a person's pick keeps one), each variable
|
|
50
|
+
* by `<collection>/<name>`.
|
|
51
|
+
*/
|
|
52
|
+
export function lockedFrom(library: FcLibrary, path: string, by: LockedLibrary['by']): LockedLibrary {
|
|
53
|
+
const collections = new Map((library.collections ?? []).map((collection) => [collection.id, collection.name]));
|
|
54
|
+
const entry: LockedLibrary = { path, id: library.id, by, components: {}, styles: {}, variables: {} };
|
|
55
|
+
for (const component of library.components ?? [])
|
|
56
|
+
entry.components[component.name] = { key: keyOf(library.id, component.id), set: component.set };
|
|
57
|
+
for (const style of library.styles ?? []) {
|
|
58
|
+
const kind = KINDS[style.type.toLowerCase()];
|
|
59
|
+
if (kind) entry.styles[style.name.split('/').join('.')] = { key: keyOf(library.id, style.id), kind };
|
|
60
|
+
}
|
|
61
|
+
for (const variable of library.variables ?? []) {
|
|
62
|
+
const collection = collections.get(variable.collection);
|
|
63
|
+
if (collection !== undefined) entry.variables[`${collection}/${variable.name}`] = keyOf(library.id, variable.id);
|
|
64
|
+
}
|
|
65
|
+
return entry;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const plural = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`;
|
|
69
|
+
|
|
70
|
+
interface Say {
|
|
71
|
+
stdout(text: string): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Publishes the pinned library file and saves it at `path`, since a library is seen by other files only once saved.
|
|
76
|
+
* FigCraft refuses a publish with nothing changed since the last (measured, 0.10.0: `no changes to publish since
|
|
77
|
+
* version N`); that is a library already in step, not a failure, so the run says so and goes on to the target.
|
|
78
|
+
*/
|
|
79
|
+
export async function publish(control: Control, pin: Pin, path: string, say: Say): Promise<number> {
|
|
80
|
+
let version: number;
|
|
81
|
+
try {
|
|
82
|
+
const [published] = (await inFile(control, pin, 'Publish library', [{ command: 'library.publish', params: {} }])) as [
|
|
83
|
+
{ version: number },
|
|
84
|
+
];
|
|
85
|
+
version = published.version;
|
|
86
|
+
say.stdout(`Published the library ${path} as version ${version}, and saved it.\n`);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
const unchanged = /no changes to publish since version (\d+)/.exec(error instanceof Error ? error.message : String(error));
|
|
89
|
+
if (!unchanged) throw error;
|
|
90
|
+
version = Number(unchanged[1]);
|
|
91
|
+
say.stdout(`The library ${path} is already published as version ${version}: nothing to publish.\n`);
|
|
92
|
+
}
|
|
93
|
+
await savePinned(control, pin, path);
|
|
94
|
+
return version;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The target's listing of the library at `path`, by its id when known. */
|
|
98
|
+
async function listed(control: Control, target: Pin, path: string, id?: string): Promise<FcLibrary | undefined> {
|
|
99
|
+
const [list] = (await inFile(control, target, 'Read', [{ command: 'library.list', params: { assets: true } }])) as [
|
|
100
|
+
{ libraries: FcLibrary[] },
|
|
101
|
+
];
|
|
102
|
+
return list.libraries.find((library) => (id !== undefined ? library.id === id : library.path === path));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* In the target: enables the library at `path` (or points it there after a move: the same id at a new path), accepts
|
|
107
|
+
* that library's updates when asked, and answers its listing, from which the lock is written.
|
|
108
|
+
*/
|
|
109
|
+
export async function enable(control: Control, target: Pin, path: string, accept: boolean, say: Say): Promise<FcLibrary> {
|
|
110
|
+
const [added] = (await inFile(control, target, 'Enable library', [{ command: 'library.add', params: { path } }])) as [{ id: string }];
|
|
111
|
+
const [updates] = (await inFile(control, target, 'Read', [{ command: 'library.updates', params: {} }])) as [
|
|
112
|
+
{ libraries: Array<{ id: string; components: unknown[]; styles: unknown[]; variables: unknown[]; collections: unknown[] }> },
|
|
113
|
+
];
|
|
114
|
+
const mine = updates.libraries.find((library) => library.id === added.id);
|
|
115
|
+
const count = mine ? mine.components.length + mine.styles.length + mine.variables.length + mine.collections.length : 0;
|
|
116
|
+
if (accept) {
|
|
117
|
+
if (count > 0)
|
|
118
|
+
await inFile(control, target, 'Accept library updates', [{ command: 'library.acceptUpdates', params: { ids: [added.id] } }]);
|
|
119
|
+
say.stdout(
|
|
120
|
+
`Enabled ${path} in "${target.tab.name}"${count > 0 ? `, and accepted ${plural(count, 'update')} from it` : ', with no update to accept'}.\n`,
|
|
121
|
+
);
|
|
122
|
+
} else if (count > 0)
|
|
123
|
+
say.stdout(
|
|
124
|
+
`${path} has ${plural(count, 'update')} for "${target.tab.name}" not accepted: a run with --library accepts them, or lock does.\n`,
|
|
125
|
+
);
|
|
126
|
+
const library = await listed(control, target, path, added.id);
|
|
127
|
+
if (!library) throw new Error(`FigCraft does not list the library ${path} in "${target.tab.name}" after enabling it.`);
|
|
128
|
+
return library;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** What a dry run says of the target: whether the library is enabled there, and the updates waiting from it. */
|
|
132
|
+
export async function describeTarget(control: Control, target: Pin, path: string, say: Say): Promise<void> {
|
|
133
|
+
const library = await listed(control, target, path);
|
|
134
|
+
if (!library) return say.stdout(`Dry run: ${path} is not enabled in "${target.tab.name}"; a run would enable it.\n`);
|
|
135
|
+
if (library.error) return say.stdout(`Dry run: "${target.tab.name}" cannot read ${path}: ${library.error}\n`);
|
|
136
|
+
say.stdout(
|
|
137
|
+
`Dry run: "${target.tab.name}" has ${plural(library.updates ?? 0, 'update')} from ${path} waiting; a run would publish the library first, then accept them.\n`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** One line counting a lock entry. */
|
|
142
|
+
export const counted = (entry: LockedLibrary): string =>
|
|
143
|
+
[
|
|
144
|
+
plural(Object.keys(entry.components).length, 'component'),
|
|
145
|
+
plural(Object.keys(entry.styles).length, 'style'),
|
|
146
|
+
plural(Object.keys(entry.variables).length, 'variable'),
|
|
147
|
+
].join(' · ');
|