@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,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A component file's document to members.
|
|
3
|
+
*
|
|
4
|
+
* A component set is the variant matrix built out: one member per combination of
|
|
5
|
+
* axis values, minus the combinations `exclude` names. Each combination is one
|
|
6
|
+
* evaluation of the file's document by kdlet, with the axis values as its inputs;
|
|
7
|
+
* the tree that comes back is that member's. This file is the Figma vocabulary
|
|
8
|
+
* kdlet is given, the matrix, and the step from kdlet's tree to the model's layers.
|
|
9
|
+
*
|
|
10
|
+
* It runs in the plugin, so it holds no Figma API, no Node API and nothing from
|
|
11
|
+
* `@toghen/lib`: the checks that need the library's reference grammar are
|
|
12
|
+
* `check.ts`'s, and are kept apart for that reason.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { literalText, referenceTarget } from '@toghen/lib';
|
|
16
|
+
import { defineVocabulary, evaluate } from 'kdlet';
|
|
17
|
+
import type { Document, Json, OutNode, Structure } from 'kdlet';
|
|
18
|
+
import { expandSegments, pinsOf, selectionOf } from './axes.js';
|
|
19
|
+
import type { Expanded } from './axes.js';
|
|
20
|
+
import { applyBindings } from './bindings.js';
|
|
21
|
+
import { FIELDS, LAYER_KINDS, STRUCTURED_FIELDS } from './fields.js';
|
|
22
|
+
import type { Component, FieldValue, Layer, LayerKind, LayoutGridRecord, Member, Modes, Problem, SetField, Track, Value } from './model.js';
|
|
23
|
+
|
|
24
|
+
/** The tags a value may carry, on top of kdlet's own `input`. */
|
|
25
|
+
export const TAGS = ['literal', 'property', 'key'] as const;
|
|
26
|
+
|
|
27
|
+
/** The word that fills an instance's slot in an artboard file: `slot Content { … }`. It is no layer, and a component file refuses it. */
|
|
28
|
+
export const SLOT_WORD = 'slot';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What kdlet is told about Figma: the layer kinds, the fields written as child
|
|
32
|
+
* nodes, the layer's name as its address, and the three tags. Every field
|
|
33
|
+
* replaces, except `set` (an artboard instance's property values), where a `when` adds
|
|
34
|
+
* to what the line before it set. `slot` is an element so that its body may hold a
|
|
35
|
+
* `when` or an `each`; only an artboard file may use it, and `check.ts` refuses it in any other.
|
|
36
|
+
*/
|
|
37
|
+
export const vocabulary = defineVocabulary({
|
|
38
|
+
elements: [...LAYER_KINDS, SLOT_WORD],
|
|
39
|
+
childFields: STRUCTURED_FIELDS,
|
|
40
|
+
adds: ['set'],
|
|
41
|
+
address: (node) => (typeof node.args[0] === 'string' ? node.args[0] : undefined),
|
|
42
|
+
tags: TAGS,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** `Axis=value, Axis=value`, as the set's members are named. */
|
|
46
|
+
export function memberName(combination: Record<string, string>): string {
|
|
47
|
+
return Object.entries(combination)
|
|
48
|
+
.map(([axis, value]) => `${axis}=${value}`)
|
|
49
|
+
.join(', ');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Every combination the set holds, in declared axis order, the last axis varying fastest.
|
|
54
|
+
*
|
|
55
|
+
* When the file writes `include`, only the combinations one of its lines matches are
|
|
56
|
+
* candidates; `exclude` then removes from those. A line names one value for each axis it
|
|
57
|
+
* mentions, and an axis it does not mention matches any value.
|
|
58
|
+
*/
|
|
59
|
+
export function combinations(component: Component): Record<string, string>[] {
|
|
60
|
+
let all: Record<string, string>[] = [{}];
|
|
61
|
+
for (const property of component.properties) {
|
|
62
|
+
if (property.type !== 'VARIANT') continue;
|
|
63
|
+
const next: Record<string, string>[] = [];
|
|
64
|
+
for (const partial of all) {
|
|
65
|
+
for (const value of property.values) next.push({ ...partial, [property.name]: value });
|
|
66
|
+
}
|
|
67
|
+
all = next;
|
|
68
|
+
}
|
|
69
|
+
const matches = (line: { combination: Record<string, string> }, combination: Record<string, string>): boolean =>
|
|
70
|
+
Object.entries(line.combination).every(([axis, value]) => combination[axis] === value);
|
|
71
|
+
return all.filter(
|
|
72
|
+
(combination) =>
|
|
73
|
+
(component.include === undefined || component.include.some((line) => matches(line, combination))) &&
|
|
74
|
+
!component.exclude.some((exclusion) => matches(exclusion, combination)),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One combination, evaluated: kdlet's tree for it, and what kdlet found wrong. */
|
|
79
|
+
export interface Evaluated {
|
|
80
|
+
/** The member's name: its combination, or the component's name when there are no axes. */
|
|
81
|
+
name: string;
|
|
82
|
+
combination: Record<string, string>;
|
|
83
|
+
roots: OutNode[];
|
|
84
|
+
problems: Problem[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Evaluates the document once per combination, the axis values being its inputs. A reference that takes a path
|
|
89
|
+
* segment from an axis (`{hue.(input)hue.soft}`) is expanded for the member, so that everything after sees an
|
|
90
|
+
* ordinary one; kdlet leaves a string as it is written, and the segment is Toghen's.
|
|
91
|
+
*/
|
|
92
|
+
export function evaluateMembers(component: Component, document: Document): Evaluated[] {
|
|
93
|
+
return combinations(component).map((combination) => {
|
|
94
|
+
const { tree, problems } = evaluate(document, { vocabulary, inputs: combination });
|
|
95
|
+
const expanded = expandSegments(tree, combination);
|
|
96
|
+
return {
|
|
97
|
+
name: Object.keys(combination).length > 0 ? memberName(combination) : component.name,
|
|
98
|
+
combination,
|
|
99
|
+
roots: expanded.roots,
|
|
100
|
+
problems: [
|
|
101
|
+
...problems.map((problem) => ({
|
|
102
|
+
severity: 'error' as const,
|
|
103
|
+
message: problem.message,
|
|
104
|
+
line: problem.line,
|
|
105
|
+
column: problem.column,
|
|
106
|
+
})),
|
|
107
|
+
...expanded.problems,
|
|
108
|
+
],
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// From kdlet's tree to layers
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/** A value carrying a host tag, as kdlet keeps it. */
|
|
118
|
+
export const tagged = (json: Json | undefined): { tag: string; value: Json } | undefined =>
|
|
119
|
+
json !== null && typeof json === 'object' && !Array.isArray(json) && 'tag' in json && 'value' in json && typeof json.tag === 'string'
|
|
120
|
+
? { tag: json.tag, value: json.value }
|
|
121
|
+
: undefined;
|
|
122
|
+
|
|
123
|
+
/** A token reference's path: `"{space.md}"` is `space.md` (lib's `referenceTarget`). The grammar was checked where the file was read. */
|
|
124
|
+
const pathOf = (text: string): string => referenceTarget(text)!;
|
|
125
|
+
|
|
126
|
+
function toValue(json: Json, field: string): Value {
|
|
127
|
+
const tag = tagged(json);
|
|
128
|
+
if (tag) {
|
|
129
|
+
if (tag.tag === 'literal') return { kind: 'literal', value: tag.value as number | string };
|
|
130
|
+
if (tag.tag === 'property') return { kind: 'property', name: tag.value as string };
|
|
131
|
+
return { kind: 'key', key: tag.value as string };
|
|
132
|
+
}
|
|
133
|
+
const spec = FIELDS[field];
|
|
134
|
+
const text =
|
|
135
|
+
spec.value === 'dimension' ||
|
|
136
|
+
spec.value === 'size' ||
|
|
137
|
+
spec.value === 'token' ||
|
|
138
|
+
spec.value === 'group' ||
|
|
139
|
+
spec.value === 'paints' ||
|
|
140
|
+
spec.value === 'dashes' ||
|
|
141
|
+
spec.value === 'effects' ||
|
|
142
|
+
spec.value === 'grids';
|
|
143
|
+
if (text && typeof json === 'string') return { kind: 'token', path: pathOf(json) };
|
|
144
|
+
// `visible` and `characters` take a token reference too; any other text is plain.
|
|
145
|
+
if (spec.token && typeof json === 'string' && referenceTarget(json) !== undefined) return { kind: 'token', path: pathOf(json) };
|
|
146
|
+
// A doubled brace is a literal one, as in a token's value: `"{{size.sm}}"` is the text `{size.sm}`. Both targets read it from here.
|
|
147
|
+
return { kind: 'plain', value: typeof json === 'string' ? literalText(json) : (json as number | boolean | null) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function toTrack(track: Structure): Track {
|
|
151
|
+
if (track.name === 'HUG') return { type: 'HUG' };
|
|
152
|
+
const [value] = track.args;
|
|
153
|
+
// A bare FLEX reads as 1.
|
|
154
|
+
return { type: track.name as 'FLEX' | 'FIXED', value: value === undefined ? 1 : (value as number) };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A layout grid's node, `COLUMNS alignment=STRETCH count=12 gutterSize="{space.md}"`, as a record: each property a value. */
|
|
158
|
+
function toLayoutGrid(grid: Structure, field: string): LayoutGridRecord {
|
|
159
|
+
const record: LayoutGridRecord = { pattern: grid.name as LayoutGridRecord['pattern'] };
|
|
160
|
+
for (const [key, raw] of Object.entries(grid.props)) {
|
|
161
|
+
if (key === 'alignment') record.alignment = raw as LayoutGridRecord['alignment'];
|
|
162
|
+
else record[key as 'count'] = toValue(raw, field);
|
|
163
|
+
}
|
|
164
|
+
return record;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function toFieldValue(field: string, json: Json): FieldValue {
|
|
168
|
+
switch (FIELDS[field].value) {
|
|
169
|
+
case 'paints':
|
|
170
|
+
case 'effects':
|
|
171
|
+
return (json as Json[]).map((paint) => toValue(paint, field));
|
|
172
|
+
case 'grids':
|
|
173
|
+
return (json as Structure).children.map((grid) => toLayoutGrid(grid, field));
|
|
174
|
+
case 'tracks':
|
|
175
|
+
return (json as Structure).children.map(toTrack);
|
|
176
|
+
case 'placement': {
|
|
177
|
+
const { props } = json as Structure;
|
|
178
|
+
const place: { row: number; column: number; rowSpan?: number; columnSpan?: number } = {
|
|
179
|
+
row: props.row as number,
|
|
180
|
+
column: props.column as number,
|
|
181
|
+
};
|
|
182
|
+
if (props.rowSpan !== undefined) place.rowSpan = props.rowSpan as number;
|
|
183
|
+
if (props.columnSpan !== undefined) place.columnSpan = props.columnSpan as number;
|
|
184
|
+
return place;
|
|
185
|
+
}
|
|
186
|
+
case 'modes':
|
|
187
|
+
return { ...((json as Structure).props as Modes) };
|
|
188
|
+
// A dash pattern is a list, as paints are: one token, or lengths; none is a solid stroke.
|
|
189
|
+
case 'dashes':
|
|
190
|
+
return Array.isArray(json) ? json.map((dash) => toValue(dash, field)) : [];
|
|
191
|
+
case 'constraints': {
|
|
192
|
+
const { props } = json as Structure;
|
|
193
|
+
return { horizontal: props.horizontal as string, vertical: props.vertical as string };
|
|
194
|
+
}
|
|
195
|
+
case 'set': {
|
|
196
|
+
// Every `set` line adds to the ones before it; a later write of one name replaces the earlier.
|
|
197
|
+
const set: SetField = {};
|
|
198
|
+
for (const line of json as Structure[]) {
|
|
199
|
+
for (const [name, raw] of Object.entries(line.props)) {
|
|
200
|
+
const tag = tagged(raw);
|
|
201
|
+
// A token reference binds its variable to the property.
|
|
202
|
+
const token = !tag && typeof raw === 'string' && referenceTarget(raw) !== undefined ? { token: pathOf(raw) } : undefined;
|
|
203
|
+
set[name] = { value: tag ? { key: tag.value as string } : (token ?? (raw as string | boolean)), loc: line.propLocs[name] };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return set;
|
|
207
|
+
}
|
|
208
|
+
default:
|
|
209
|
+
return toValue(json, field);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A validated node of kdlet's tree as a layer. Its positions come with it: a problem found later still names the line. */
|
|
214
|
+
export function toLayer(node: OutNode): Layer {
|
|
215
|
+
const fields: Record<string, FieldValue> = {};
|
|
216
|
+
for (const [field, json] of Object.entries(node.fields)) fields[field] = toFieldValue(field, json);
|
|
217
|
+
const layer: Layer = {
|
|
218
|
+
kind: node.name as LayerKind,
|
|
219
|
+
address: String(node.args[0]),
|
|
220
|
+
fields,
|
|
221
|
+
children: node.children.map(toLayer),
|
|
222
|
+
loc: node.loc,
|
|
223
|
+
fieldLoc: { ...node.fieldLocs },
|
|
224
|
+
};
|
|
225
|
+
const fromAxes = (node as Expanded).fromAxes;
|
|
226
|
+
if (fromAxes) layer.fromAxes = fromAxes;
|
|
227
|
+
return layer;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A validated node of an artboard file's tree as a layer.
|
|
232
|
+
*
|
|
233
|
+
* A layer inside an `each` has the keys of the `each`es around it after its name, so that the rows of one
|
|
234
|
+
* template have addresses of their own: `Row["a"]`, or `Cell["a",2]` inside two. An `INSTANCE`'s `slot`
|
|
235
|
+
* nodes are flattened: the layers inside one become children of the instance, each marked with its slot.
|
|
236
|
+
*/
|
|
237
|
+
export function toArtboardLayer(node: OutNode, slot?: string): Layer {
|
|
238
|
+
const fields: Record<string, FieldValue> = {};
|
|
239
|
+
for (const [field, json] of Object.entries(node.fields)) fields[field] = toFieldValue(field, json);
|
|
240
|
+
const name = String(node.args[0]);
|
|
241
|
+
const layer: Layer = {
|
|
242
|
+
kind: node.name as LayerKind,
|
|
243
|
+
address: node.identity.keys.length > 0 ? `${name}${JSON.stringify(node.identity.keys)}` : name,
|
|
244
|
+
fields,
|
|
245
|
+
children: [],
|
|
246
|
+
loc: node.loc,
|
|
247
|
+
fieldLoc: { ...node.fieldLocs },
|
|
248
|
+
};
|
|
249
|
+
if (layer.address !== name) layer.name = name;
|
|
250
|
+
if (slot !== undefined) layer.slot = slot;
|
|
251
|
+
for (const child of node.children) {
|
|
252
|
+
if (child.name === SLOT_WORD) {
|
|
253
|
+
(layer.slots ??= []).push({ name: String(child.args[0]), loc: child.loc });
|
|
254
|
+
for (const inner of child.children) layer.children.push(toArtboardLayer(inner, String(child.args[0])));
|
|
255
|
+
} else {
|
|
256
|
+
layer.children.push(toArtboardLayer(child));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return layer;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* A validated evaluation as a member.
|
|
264
|
+
*
|
|
265
|
+
* An axis that takes its values from a modifier pins that modifier's collection on the
|
|
266
|
+
* member's root to the member's value, with no line in the file: `modes state=hover`. The
|
|
267
|
+
* file may not write a pin of its own for the same collection (`checkPins`), so two writers
|
|
268
|
+
* of one pin never disagree here.
|
|
269
|
+
*
|
|
270
|
+
* `tokens=` on a layer (and on the component line, for the root) becomes the fields the
|
|
271
|
+
* group's keys imply, here and not earlier: only the evaluated layer knows which fields a
|
|
272
|
+
* `when` has already written, and those win. `seen` is told what each layer got.
|
|
273
|
+
*/
|
|
274
|
+
export function toMember(evaluated: Evaluated, component?: Component, seen?: Parameters<typeof applyBindings>[2]): Member {
|
|
275
|
+
const tree = toLayer(evaluated.roots[0]);
|
|
276
|
+
if (component) applyBindings(tree, component, seen, selectionOf(component, evaluated.combination));
|
|
277
|
+
const pins = component ? pinsOf(component, evaluated.combination) : {};
|
|
278
|
+
if (Object.keys(pins).length > 0) tree.fields.modes = { ...((tree.fields.modes as Modes | undefined) ?? {}), ...pins };
|
|
279
|
+
return { name: evaluated.name, combination: evaluated.combination, tree };
|
|
280
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model a component file reads into.
|
|
3
|
+
*
|
|
4
|
+
* The file is a kdlet file: kdlet reads it and evaluates it, and this is what a
|
|
5
|
+
* member's tree becomes once it is Figma's. Everything downstream (the diff, the
|
|
6
|
+
* steps, the engine) works on these types and never on KDL, and every type is
|
|
7
|
+
* plain JSON. The file format
|
|
8
|
+
* itself is owned by `reference.md`, *Component files*.
|
|
9
|
+
*
|
|
10
|
+
* Positions are kept beside the data they describe (`loc`, `fieldLoc`) rather
|
|
11
|
+
* than in a side table, so a problem found in a member after a `when` wrote the
|
|
12
|
+
* offending field still points at the line of that `when`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A position in the file, one-based, as an editor shows it. */
|
|
16
|
+
export interface Loc {
|
|
17
|
+
line: number;
|
|
18
|
+
column: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Problem {
|
|
22
|
+
severity: 'error' | 'warning';
|
|
23
|
+
/** In the file's own words: names the layer, field or property it is about. */
|
|
24
|
+
message: string;
|
|
25
|
+
line?: number;
|
|
26
|
+
column?: number;
|
|
27
|
+
/** The member the problem was found in, for problems that only exist after expansion. */
|
|
28
|
+
member?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What a field holds. `plain` is an enum, a boolean, a string, or `null` (`#null`,
|
|
33
|
+
* which clears a size and is distinct from a field that was never written).
|
|
34
|
+
*/
|
|
35
|
+
export type Value =
|
|
36
|
+
/** `"{space.md}"`: the path without braces. */
|
|
37
|
+
| { kind: 'token'; path: string }
|
|
38
|
+
/** `(literal)8`, `(literal)"#ffffff00"`: a value that says it is on purpose. On a wirable field (`characters=(literal)"Ready"`) it takes the field off its property. */
|
|
39
|
+
| { kind: 'literal'; value: number | string | boolean }
|
|
40
|
+
/** `(property)"Show Nav"`: wired to a component property. */
|
|
41
|
+
| { kind: 'property'; name: string }
|
|
42
|
+
/** `(key)"…"`: a published component key. Accepted only where a component is named. */
|
|
43
|
+
| { kind: 'key'; key: string }
|
|
44
|
+
| { kind: 'plain'; value: string | number | boolean | null };
|
|
45
|
+
|
|
46
|
+
export interface Track {
|
|
47
|
+
type: 'FLEX' | 'FIXED' | 'HUG';
|
|
48
|
+
/** Present for `FLEX` (a bare `FLEX` reads as 1) and `FIXED`. */
|
|
49
|
+
value?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface GridPlacement {
|
|
53
|
+
row: number;
|
|
54
|
+
column: number;
|
|
55
|
+
rowSpan?: number;
|
|
56
|
+
columnSpan?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `modes Scale=small`: collection name to mode name. */
|
|
60
|
+
export type Modes = Record<string, string>;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `set Name=value …` on an instance: the values of the instance's properties, by the display
|
|
64
|
+
* name the component declares. A text or a variant value is a string, a boolean is a boolean, and an
|
|
65
|
+
* instance swap is a component's name or `(key)"…"`. A token reference (`"{copy.title}"`) is `{ token }`, its path: a
|
|
66
|
+
* `string` or `boolean` token whose variable is bound to a `TEXT` or `BOOLEAN` property. `loc` is the line the value was
|
|
67
|
+
* written at.
|
|
68
|
+
*/
|
|
69
|
+
export type SetField = Record<string, { value: string | boolean | { key: string } | { token: string }; loc?: Loc }>;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One layout grid of `layoutGrids` (unit 4): `COLUMNS` and `ROWS` with an alignment, or a square `GRID`. Each field holds
|
|
73
|
+
* a token, a `(literal)` (a dimension, or a colour as `#rrggbbaa`) or, for `count`, a plain whole number; a field the
|
|
74
|
+
* grid's alignment does not take is refused when the file is read (`GRID_FIELDS`).
|
|
75
|
+
*/
|
|
76
|
+
export interface LayoutGridRecord {
|
|
77
|
+
pattern: 'COLUMNS' | 'ROWS' | 'GRID';
|
|
78
|
+
alignment?: 'MIN' | 'MAX' | 'STRETCH' | 'CENTER';
|
|
79
|
+
count?: Value;
|
|
80
|
+
gutterSize?: Value;
|
|
81
|
+
offset?: Value;
|
|
82
|
+
sectionSize?: Value;
|
|
83
|
+
color?: Value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `constraints horizontal=MIN vertical=STRETCH`: Figma's `Constraints`, both axes given. */
|
|
87
|
+
export interface Constraints {
|
|
88
|
+
horizontal: string;
|
|
89
|
+
vertical: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Which shape a field's value has follows from the field's name: see `fields.ts`. `dashPattern` is a `Value[]`, as `fills` is. */
|
|
93
|
+
export type FieldValue = Value | Value[] | Track[] | GridPlacement | Modes | SetField | Constraints | LayoutGridRecord[];
|
|
94
|
+
|
|
95
|
+
export type LayerKind = 'FRAME' | 'SLOT' | 'TEXT' | 'INSTANCE' | 'LINE';
|
|
96
|
+
|
|
97
|
+
export interface Layer {
|
|
98
|
+
kind: LayerKind;
|
|
99
|
+
/**
|
|
100
|
+
* The layer's name, and the key rules and stamps find it by. For a `SLOT`, the slot property's name.
|
|
101
|
+
* In an artboard file the keys of the `each`es around a layer follow its name (`Row["a"]`), so a repeated
|
|
102
|
+
* layer has an address of its own; `name` is then what Figma shows.
|
|
103
|
+
*/
|
|
104
|
+
address: string;
|
|
105
|
+
/** In an artboard file, the layer's name as written, when the address carries keys after it. */
|
|
106
|
+
name?: string;
|
|
107
|
+
/** In an artboard file, the slot of the enclosing `INSTANCE` this layer fills; it is one of that instance's `children`. */
|
|
108
|
+
slot?: string;
|
|
109
|
+
/** In an artboard file, the slots an `INSTANCE` fills, even those it fills with nothing. */
|
|
110
|
+
slots?: { name: string; loc?: Loc }[];
|
|
111
|
+
fields: Record<string, FieldValue>;
|
|
112
|
+
children: Layer[];
|
|
113
|
+
loc?: Loc;
|
|
114
|
+
/** Where each field was last written: on the layer's own line or body, or in the `when` that wrote it. */
|
|
115
|
+
fieldLoc?: Record<string, Loc>;
|
|
116
|
+
/** The axes a field's reference took a path segment from (`{hue.(input)hue.soft}`), by field: so a message can name the value that made it. Absent when none did. */
|
|
117
|
+
fromAxes?: Record<string, string[]>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A component offered first by an `INSTANCE_SWAP` or a `SLOT`: by its published key, or by its name, which the locator
|
|
122
|
+
* answers as it answers an instance swap's default (the file, or a declared library). `loc` is the `preferred` line.
|
|
123
|
+
*/
|
|
124
|
+
export type PreferredValue =
|
|
125
|
+
| { type: 'COMPONENT' | 'COMPONENT_SET'; key: string }
|
|
126
|
+
| { type: 'COMPONENT' | 'COMPONENT_SET'; name: string; loc?: Loc };
|
|
127
|
+
|
|
128
|
+
/** Either a component named in the file's document, or a published key. */
|
|
129
|
+
export type ComponentRef = { name: string } | { key: string };
|
|
130
|
+
|
|
131
|
+
export interface SlotSettings {
|
|
132
|
+
minChildren?: number;
|
|
133
|
+
maxChildren?: number;
|
|
134
|
+
allowPreferredValuesOnly?: boolean;
|
|
135
|
+
stretchChildOnInsert?: boolean;
|
|
136
|
+
displayEmptyByDefault?: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type Property =
|
|
140
|
+
/**
|
|
141
|
+
* `modifier` is set for an axis that takes its values from a modifier of the design
|
|
142
|
+
* system (`VARIANT State (modifier)"state"`), or that was inferred from the tokens:
|
|
143
|
+
* `values` are then the modifier's contexts, default first, and each member pins the
|
|
144
|
+
* modifier's collection to its value (`modes state=hover`) on its root layer. `leftOut` are the
|
|
145
|
+
* contexts of that modifier Figma does not get (its `figma.include`): not values, and said so when a file names one.
|
|
146
|
+
*
|
|
147
|
+
* `part` is set for an axis that `tokens=` took from the parts vocabulary: `values` are `rest` and the part keys.
|
|
148
|
+
* A folded one also has `modifier`, the collection named after the axis; one left in the path pins nothing, and
|
|
149
|
+
* its value selects the part group the member's layers bind.
|
|
150
|
+
*
|
|
151
|
+
* `from` is set for an axis that takes its values from a token group (`VARIANT hue from="{hue}"`): the group's
|
|
152
|
+
* path without braces. `values` are then the names of its children, in the document's order.
|
|
153
|
+
*/
|
|
154
|
+
(
|
|
155
|
+
| { type: 'VARIANT'; name: string; values: string[]; modifier?: string; leftOut?: string[]; from?: string; part?: true }
|
|
156
|
+
/** A default that is a token reference (`default="{flag.on}"`) is `{ token }`, its path: the token's variable is bound to it. */
|
|
157
|
+
| { type: 'BOOLEAN'; name: string; default: boolean | { token: string } }
|
|
158
|
+
| { type: 'TEXT'; name: string; default: string | { token: string } }
|
|
159
|
+
| { type: 'INSTANCE_SWAP'; name: string; default: ComponentRef; preferredValues?: PreferredValue[] }
|
|
160
|
+
| {
|
|
161
|
+
type: 'SLOT';
|
|
162
|
+
name: string;
|
|
163
|
+
description?: string;
|
|
164
|
+
preferredValues?: PreferredValue[];
|
|
165
|
+
slotSettings?: SlotSettings;
|
|
166
|
+
}
|
|
167
|
+
) & { loc?: Loc };
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A combination that is never built (`exclude`), or the only ones that are built
|
|
171
|
+
* (`include`): one value for each axis it names, an axis it does not name matching any value.
|
|
172
|
+
*/
|
|
173
|
+
export interface Exclusion {
|
|
174
|
+
combination: Record<string, string>;
|
|
175
|
+
loc?: Loc;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* What a component file says apart from its layers: the set's name, description and
|
|
180
|
+
* properties, and the combinations never built. The layers are the file's
|
|
181
|
+
* document, which `members.ts` evaluates once per combination.
|
|
182
|
+
*/
|
|
183
|
+
export interface Component {
|
|
184
|
+
name: string;
|
|
185
|
+
description?: string;
|
|
186
|
+
properties: Property[];
|
|
187
|
+
exclude: Exclusion[];
|
|
188
|
+
/**
|
|
189
|
+
* When present, only the combinations one of these matches are built, and `exclude`
|
|
190
|
+
* then removes from those. Absent when the file writes none: every combination is a candidate.
|
|
191
|
+
*/
|
|
192
|
+
include?: Exclusion[];
|
|
193
|
+
/**
|
|
194
|
+
* The token group the component names (`tokens="{button}"`), without the braces. Every
|
|
195
|
+
* axis of the parts vocabulary the group's subgroups use is an axis, after the declared ones.
|
|
196
|
+
*/
|
|
197
|
+
tokens?: string;
|
|
198
|
+
/** `parts` (`layout=parts` on the component line): every axis of the component stays in the path, one variable per part. */
|
|
199
|
+
layout?: 'parts';
|
|
200
|
+
/**
|
|
201
|
+
* The axes that go across on the set's board (`across="a, b"` on the component line), by name; the others go down.
|
|
202
|
+
* Absent, the board balances itself (`memberBoard`).
|
|
203
|
+
*/
|
|
204
|
+
across?: string[];
|
|
205
|
+
/** Where `across=` was written, for the refusal that names an axis the component does not have. */
|
|
206
|
+
acrossLoc?: Loc;
|
|
207
|
+
/**
|
|
208
|
+
* What each token group the component's layers name with `tokens=` (and the group the component
|
|
209
|
+
* line names) binds, by the group's path. Filled in by the reader from the design system, so the
|
|
210
|
+
* engine, which asks no design system, infers the same bindings from it. A member that selects part groups
|
|
211
|
+
* (`variant=primary`) has its own plan, stored under `planKey(group, selection)`.
|
|
212
|
+
*/
|
|
213
|
+
groups?: Record<string, GroupPlan>;
|
|
214
|
+
/**
|
|
215
|
+
* What the reader found while inferring bindings, for the run's report: keys with no field on the
|
|
216
|
+
* layer that names them (notes), and what could not be bound (warnings). Said once per key and layer.
|
|
217
|
+
*/
|
|
218
|
+
notes?: string[];
|
|
219
|
+
warnings?: string[];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* What a group binds, worked out from its keys by the reader (`groups.ts`) so that the engine only
|
|
224
|
+
* has to lay it over the evaluated layers (`bindings.ts`). Plain JSON, and small: it travels in the script.
|
|
225
|
+
*/
|
|
226
|
+
export interface GroupPlan {
|
|
227
|
+
/** For a `TEXT`. */
|
|
228
|
+
TEXT: Laid;
|
|
229
|
+
/** For a `FRAME` or `SLOT`, whatever its layout. */
|
|
230
|
+
box: Laid;
|
|
231
|
+
/**
|
|
232
|
+
* Laid over `box` by the layer's `layoutMode` (a layer with none is `NONE`), present only where the group has
|
|
233
|
+
* a gap: it is `itemSpacing` in a flow, the two grid gaps in a grid, and nothing otherwise.
|
|
234
|
+
*/
|
|
235
|
+
NONE?: Laid;
|
|
236
|
+
HORIZONTAL?: Laid;
|
|
237
|
+
VERTICAL?: Laid;
|
|
238
|
+
GRID?: Laid;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** What one kind of layer gets from a group. */
|
|
242
|
+
export interface Laid {
|
|
243
|
+
/** Figma field to the path of the token it binds to. */
|
|
244
|
+
set: Record<string, string>;
|
|
245
|
+
/** Keys with no field here, said in the run's notes, one line each. */
|
|
246
|
+
notes?: string[];
|
|
247
|
+
/** What the group could not bind here, said as warnings: a composite border's literal members. */
|
|
248
|
+
warnings?: string[];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The tokens one level down in a group, as the design system gives them: what a `GroupPlan` is worked out from. */
|
|
252
|
+
export interface GroupInfo {
|
|
253
|
+
/** The keys of the tokens directly in the group (`backgroundColor`), in the document's order. */
|
|
254
|
+
keys: string[];
|
|
255
|
+
/** For a key whose token is in a part group the selection chose, the token's path; every other key is `<group>.<key>`. */
|
|
256
|
+
paths?: Record<string, string>;
|
|
257
|
+
/**
|
|
258
|
+
* For a key whose token is a composite `border`: the tokens its `color` and `width` members
|
|
259
|
+
* refer to, as paths. A member that is not here is a literal. `unsolid` is set when its style
|
|
260
|
+
* is anything but `solid`, which the reader refuses.
|
|
261
|
+
*/
|
|
262
|
+
borders?: Record<string, { color?: string; width?: string; unsolid?: true }>;
|
|
263
|
+
/** The keys, among those the table asks the type of (`background`), whose token is a colour in every context. */
|
|
264
|
+
colors?: string[];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** One component of the set: a combination of variant values and the tree it builds. */
|
|
268
|
+
export interface Member {
|
|
269
|
+
/** `Axis=value, Axis=value` in declared axis order; the component's name when there are no axes. */
|
|
270
|
+
name: string;
|
|
271
|
+
combination: Record<string, string>;
|
|
272
|
+
tree: Layer;
|
|
273
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The order a run's files go in: each file after every file that defines a component it names.
|
|
3
|
+
*
|
|
4
|
+
* A file that names a component a later file defines would find nothing, since that set is not built yet. So files
|
|
5
|
+
* are sorted by what they define and name; ties keep the order they were typed in, so only a file with a dependency
|
|
6
|
+
* moves. A name a declared library defines resolves there, and is no dependency on a file. A cycle is refused, naming
|
|
7
|
+
* its files. An artboard defines nothing, so it never comes before a component file it could follow.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface FileNames {
|
|
11
|
+
fileName: string;
|
|
12
|
+
/** The component the file defines; none for an artboard, or a file that does not read. */
|
|
13
|
+
defines?: string;
|
|
14
|
+
/** The components it names by name. */
|
|
15
|
+
names: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `files` in the order to run them. Throws when they name each other in a cycle, with the files of the cycle in order. */
|
|
19
|
+
export function orderFiles<T extends FileNames>(files: readonly T[], fromLibraries: ReadonlySet<string> = new Set()): T[] {
|
|
20
|
+
const definer = new Map<string, number>();
|
|
21
|
+
files.forEach((file, i) => {
|
|
22
|
+
if (file.defines !== undefined && !definer.has(file.defines)) definer.set(file.defines, i);
|
|
23
|
+
});
|
|
24
|
+
const after = files.map((file, i) => {
|
|
25
|
+
const on = new Set<number>();
|
|
26
|
+
for (const name of file.names) {
|
|
27
|
+
const at = definer.get(name);
|
|
28
|
+
if (at !== undefined && at !== i && !fromLibraries.has(name)) on.add(at);
|
|
29
|
+
}
|
|
30
|
+
return [...on];
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const done = new Set<number>();
|
|
34
|
+
const ordered: T[] = [];
|
|
35
|
+
while (ordered.length < files.length) {
|
|
36
|
+
// The first file typed whose every dependency has run: what keeps the typed order among ties.
|
|
37
|
+
const next = files.findIndex((_, i) => !done.has(i) && after[i].every((at) => done.has(at)));
|
|
38
|
+
if (next === -1) {
|
|
39
|
+
// Every file left waits on another left: follow the waits from one until a file comes round again.
|
|
40
|
+
const path: number[] = [];
|
|
41
|
+
let at = files.findIndex((_, i) => !done.has(i));
|
|
42
|
+
while (!path.includes(at)) {
|
|
43
|
+
path.push(at);
|
|
44
|
+
at = after[at].find((dependency) => !done.has(dependency))!;
|
|
45
|
+
}
|
|
46
|
+
const cycle = [...path.slice(path.indexOf(at)), at].map((i) => files[i].fileName);
|
|
47
|
+
throw new Error(`the component files name each other in a cycle, so none of them can be built first: ${cycle.join(' → ')}`);
|
|
48
|
+
}
|
|
49
|
+
done.add(next);
|
|
50
|
+
ordered.push(files[next]);
|
|
51
|
+
}
|
|
52
|
+
return ordered;
|
|
53
|
+
}
|