@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,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The token groups a component file names, read from the design system.
|
|
3
|
+
*
|
|
4
|
+
* `tokens="{group}"` on a layer, and on the component line for the root, infers bindings
|
|
5
|
+
* from the keys of the group's tokens (`bindings.ts`). That needs what the group holds, and
|
|
6
|
+
* only the reader has the design system: the answer is stored on the component
|
|
7
|
+
* (`Component.groups`), so the engine infers the same bindings without one. Like
|
|
8
|
+
* `resolveAxes`, this is the reader's step.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { referenceTarget } from '@toghen/lib';
|
|
12
|
+
import type { OutNode } from 'kdlet';
|
|
13
|
+
import { planKey, viaAxes } from './axes.js';
|
|
14
|
+
import type { DesignSource, Expanded } from './axes.js';
|
|
15
|
+
import { FIELDS } from './fields.js';
|
|
16
|
+
import type { Component, GroupInfo, GroupPlan, Laid, Layer, Loc, Problem } from './model.js';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// The table: which Figma fields a CSS key fills. DTCG does not know; this adapter does.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** A Figma field and how specific the key that offers it is: the higher rank wins, whatever the document's order. */
|
|
23
|
+
type Target = [field: string, rank: number];
|
|
24
|
+
|
|
25
|
+
const SIDES = ['Top', 'Right', 'Bottom', 'Left'] as const;
|
|
26
|
+
|
|
27
|
+
/** Keys whose Figma field has the same name, on every kind of layer. */
|
|
28
|
+
const SAME = ['opacity', 'width', 'height', 'minWidth', 'maxWidth', 'minHeight', 'maxHeight'];
|
|
29
|
+
|
|
30
|
+
/** The keys whose field depends on the layer's layout. */
|
|
31
|
+
const GAPS = ['gap', 'rowGap', 'columnGap'];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The fields a key writes on a `TEXT` or on a `FRAME` or `SLOT`, or `undefined` when it writes none.
|
|
35
|
+
*
|
|
36
|
+
* Figma has only horizontal writing, so `paddingBlock` is top and bottom and `paddingInline` left and
|
|
37
|
+
* right. A more specific key outranks a general one for the same field: `paddingTop` over `paddingBlock`
|
|
38
|
+
* over `padding`, `borderColor` and `borderWidth` over a composite `border`.
|
|
39
|
+
*/
|
|
40
|
+
function targets(key: string, text: boolean, color: boolean): Target[] | undefined {
|
|
41
|
+
if (SAME.includes(key)) return [[key, 1]];
|
|
42
|
+
if (text) return key === 'color' ? [['fills', 1]] : key === 'font' ? [['textStyle', 1]] : undefined;
|
|
43
|
+
const padding = /^padding(Top|Right|Bottom|Left)$/.exec(key);
|
|
44
|
+
if (padding) return [[key, 3]];
|
|
45
|
+
const stroke = /^border(Top|Right|Bottom|Left)Width$/.exec(key);
|
|
46
|
+
if (stroke) return [[`stroke${stroke[1]}Weight`, 3]];
|
|
47
|
+
const corner = /^border(Top|Bottom)(Left|Right)Radius$/.exec(key);
|
|
48
|
+
if (corner) return [[`${corner[1].toLowerCase()}${corner[2]}Radius`, 3]];
|
|
49
|
+
switch (key) {
|
|
50
|
+
case 'backgroundColor':
|
|
51
|
+
return [['fills', 1]];
|
|
52
|
+
// The shorthand, when the token holds one colour: a gradient or an image has no fill to bind. `backgroundColor` outranks it.
|
|
53
|
+
case 'background':
|
|
54
|
+
return color ? [['fills', 0]] : undefined;
|
|
55
|
+
case 'borderColor':
|
|
56
|
+
return [['strokes', 2]];
|
|
57
|
+
case 'borderWidth':
|
|
58
|
+
return [['strokeWeight', 2]];
|
|
59
|
+
case 'borderRadius':
|
|
60
|
+
return [['cornerRadius', 1]];
|
|
61
|
+
case 'padding':
|
|
62
|
+
return SIDES.map((side): Target => [`padding${side}`, 1]);
|
|
63
|
+
case 'paddingBlock':
|
|
64
|
+
return [
|
|
65
|
+
['paddingTop', 2],
|
|
66
|
+
['paddingBottom', 2],
|
|
67
|
+
];
|
|
68
|
+
case 'paddingInline':
|
|
69
|
+
return [
|
|
70
|
+
['paddingLeft', 2],
|
|
71
|
+
['paddingRight', 2],
|
|
72
|
+
];
|
|
73
|
+
default:
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The Figma scope a field belongs to, as the field table holds it: the pickers a variable bound to it should be offered in. The field is one
|
|
80
|
+
* `targets()` gives; a fill is a frame's or a text's. Variable sync reads the same table, so a variable is
|
|
81
|
+
* scoped to the fields the component files will bind it to.
|
|
82
|
+
*/
|
|
83
|
+
export function scopesOfField(field: string, text: boolean): VariableScope[] {
|
|
84
|
+
const scope = FIELDS[field]?.token?.bind?.scope ?? [];
|
|
85
|
+
return [...('text' in scope ? (text ? scope.text : scope.other) : scope)];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The scopes a token whose last key is `key` is for, on a text layer and on a frame, or `undefined` when the
|
|
90
|
+
* key fills no field. A key is read as a variable holds one colour, as `background` does.
|
|
91
|
+
*/
|
|
92
|
+
export function scopesOfKey(key: string): VariableScope[] | undefined {
|
|
93
|
+
const scopes = new Set<VariableScope>();
|
|
94
|
+
for (const text of [true, false]) {
|
|
95
|
+
for (const [field] of targets(key, text, true) ?? []) for (const scope of scopesOfField(field, text)) scopes.add(scope);
|
|
96
|
+
}
|
|
97
|
+
if (GAPS.includes(key)) scopes.add('GAP');
|
|
98
|
+
return scopes.size > 0 ? [...scopes] : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Works out what a group binds on each kind of layer, from the keys of its tokens. */
|
|
102
|
+
export function planOf(group: string, info: GroupInfo): GroupPlan {
|
|
103
|
+
const path = (key: string): string => info.paths?.[key] ?? `${group}.${key}`;
|
|
104
|
+
const has = (key: string): boolean => info.keys.includes(key);
|
|
105
|
+
const needs = (key: string, what: string): string => `"${key}" needs ${what}, skipped`;
|
|
106
|
+
const laid = (): Laid => ({ set: {} });
|
|
107
|
+
|
|
108
|
+
/** What the keys that do not depend on the layout bind on a `TEXT`, or on a `FRAME` or `SLOT`. */
|
|
109
|
+
const laidOver = (text: boolean): Laid => {
|
|
110
|
+
const offers = new Map<string, { rank: number; path: string }>();
|
|
111
|
+
const offer = (field: string, rank: number, to: string): void => {
|
|
112
|
+
const held = offers.get(field);
|
|
113
|
+
if (!held || rank > held.rank) offers.set(field, { rank, path: to });
|
|
114
|
+
};
|
|
115
|
+
const out = laid();
|
|
116
|
+
for (const key of info.keys) {
|
|
117
|
+
// Only the key `border` is the composite the table knows. Variable sync makes no variable for it: its
|
|
118
|
+
// members are what bind. A member that is a literal has no variable, and a literal written in its place
|
|
119
|
+
// would stop following the tokens, so it is left out and said.
|
|
120
|
+
const border = key === 'border' && !text ? info.borders?.[key] : undefined;
|
|
121
|
+
if (border) {
|
|
122
|
+
for (const [member, field, to] of [
|
|
123
|
+
['color', 'strokes', border.color],
|
|
124
|
+
['width', 'strokeWeight', border.width],
|
|
125
|
+
] as const) {
|
|
126
|
+
if (to !== undefined) offer(field, 1, to);
|
|
127
|
+
else (out.warnings ??= []).push(`the ${member} of "${key}" is a literal, which has no variable to bind, so it was left out`);
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (!text && GAPS.includes(key)) continue;
|
|
132
|
+
const to = targets(key, text, info.colors?.includes(key) ?? false);
|
|
133
|
+
if (to) for (const [field, rank] of to) offer(field, rank, path(key));
|
|
134
|
+
else (out.notes ??= []).push(`"${key}" has no field on this layer in Figma, skipped`);
|
|
135
|
+
}
|
|
136
|
+
for (const [field, { path: to }] of offers) out.set[field] = to;
|
|
137
|
+
return out;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* What the gaps bind on a `FRAME` or `SLOT` of this layout. A gap spaces children: `itemSpacing` in a flow, the
|
|
142
|
+
* two grid gaps in a grid, nothing in a layer with no layout. `rowGap` and `columnGap` outrank `gap`, in a grid only.
|
|
143
|
+
*/
|
|
144
|
+
const gapsOver = (layout: 'NONE' | 'HORIZONTAL' | 'VERTICAL' | 'GRID'): Laid | undefined => {
|
|
145
|
+
const out = laid();
|
|
146
|
+
const note = (line: string): void => void (out.notes ??= []).push(line);
|
|
147
|
+
if (layout === 'GRID') {
|
|
148
|
+
const row = has('rowGap') ? 'rowGap' : has('gap') ? 'gap' : undefined;
|
|
149
|
+
const column = has('columnGap') ? 'columnGap' : has('gap') ? 'gap' : undefined;
|
|
150
|
+
if (row) out.set.gridRowGap = path(row);
|
|
151
|
+
if (column) out.set.gridColumnGap = path(column);
|
|
152
|
+
} else {
|
|
153
|
+
if (has('gap')) {
|
|
154
|
+
if (layout === 'NONE') note(needs('gap', 'a layer whose layoutMode is HORIZONTAL, VERTICAL or GRID'));
|
|
155
|
+
else out.set.itemSpacing = path('gap');
|
|
156
|
+
}
|
|
157
|
+
for (const key of ['rowGap', 'columnGap']) if (has(key)) note(needs(key, 'a GRID layer'));
|
|
158
|
+
}
|
|
159
|
+
return Object.keys(out.set).length > 0 || out.notes ? out : undefined;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const plan: GroupPlan = { TEXT: laidOver(true), box: laidOver(false) };
|
|
163
|
+
for (const layout of ['NONE', 'HORIZONTAL', 'VERTICAL', 'GRID'] as const) {
|
|
164
|
+
const over = gapsOver(layout);
|
|
165
|
+
if (over) plan[layout] = over;
|
|
166
|
+
}
|
|
167
|
+
return plan;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A group a layer names, and where. */
|
|
171
|
+
export interface GroupUse {
|
|
172
|
+
path: string;
|
|
173
|
+
loc?: Loc;
|
|
174
|
+
/** The axis values the path was expanded from (`hue=rose`), for a message; absent when it took none. */
|
|
175
|
+
via?: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Every `tokens=` in one member's evaluated tree, as the group path and the line it was written at. */
|
|
179
|
+
export function groupsUsed(roots: OutNode[], combination: Record<string, string> = {}): GroupUse[] {
|
|
180
|
+
const used: GroupUse[] = [];
|
|
181
|
+
const visit = (node: OutNode): void => {
|
|
182
|
+
const group = node.kind === 'element' ? node.fields.tokens : undefined;
|
|
183
|
+
const axes = (node as Expanded).fromAxes?.tokens;
|
|
184
|
+
// The form was checked (`check.ts`): a whole reference in quotes.
|
|
185
|
+
if (typeof group === 'string')
|
|
186
|
+
used.push({
|
|
187
|
+
path: referenceTarget(group)!,
|
|
188
|
+
loc: node.fieldLocs.tokens ?? node.loc,
|
|
189
|
+
...(axes ? { via: viaAxes(axes, combination) } : {}),
|
|
190
|
+
});
|
|
191
|
+
for (const child of node.children) visit(child);
|
|
192
|
+
};
|
|
193
|
+
for (const root of roots) visit(root);
|
|
194
|
+
return used;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function lookup(
|
|
198
|
+
source: DesignSource | undefined,
|
|
199
|
+
path: string,
|
|
200
|
+
loc: Loc | undefined,
|
|
201
|
+
problems: Problem[],
|
|
202
|
+
selection?: Record<string, string>,
|
|
203
|
+
): GroupInfo | 'token' | undefined | 'failed' {
|
|
204
|
+
const fail = (message: string): 'failed' => {
|
|
205
|
+
problems.push({ severity: 'error', message, ...(loc ?? {}) });
|
|
206
|
+
return 'failed';
|
|
207
|
+
};
|
|
208
|
+
if (!source?.group)
|
|
209
|
+
return fail(
|
|
210
|
+
`tokens="{${path}}" takes its bindings from the tokens it names, and no design system is loaded; load its resolver document in the plugin, or pass it to the command line`,
|
|
211
|
+
);
|
|
212
|
+
try {
|
|
213
|
+
return source.group(path, selection);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
return fail(`tokens="{${path}}": the design system could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A border whose style is not solid cannot be bound: Figma's dash pattern is not in this design. */
|
|
220
|
+
function unsolid(info: GroupInfo, path: string, loc: Loc | undefined, problems: Problem[]): void {
|
|
221
|
+
for (const [key, border] of Object.entries(info.borders ?? {})) {
|
|
222
|
+
if (border.unsolid) {
|
|
223
|
+
problems.push({
|
|
224
|
+
severity: 'error',
|
|
225
|
+
message: `tokens="{${path}}": the border "${path}.${key}" has a style other than solid, and Figma's dash pattern is not in this design; it cannot be bound`,
|
|
226
|
+
...(loc ?? {}),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Stores what each group a member's layers name holds, on the component, and refuses a group
|
|
234
|
+
* that is a token or names nothing (an `INSTANCE` is refused where the field is checked).
|
|
235
|
+
*/
|
|
236
|
+
export function resolveGroups(
|
|
237
|
+
component: Component,
|
|
238
|
+
used: GroupUse[],
|
|
239
|
+
source: DesignSource | undefined,
|
|
240
|
+
selection: Record<string, string> = {},
|
|
241
|
+
): Problem[] {
|
|
242
|
+
const problems: Problem[] = [];
|
|
243
|
+
for (const { path, loc, via } of used) {
|
|
244
|
+
const found = lookup(source, path, loc, problems, selection);
|
|
245
|
+
if (found === 'failed') continue;
|
|
246
|
+
const from = via ? ` (taken from ${via})` : '';
|
|
247
|
+
if (found === undefined)
|
|
248
|
+
problems.push({
|
|
249
|
+
severity: 'error',
|
|
250
|
+
message: `tokens="{${path}}"${from} names no token and no group of the design system`,
|
|
251
|
+
...(loc ?? {}),
|
|
252
|
+
});
|
|
253
|
+
else if (found === 'token')
|
|
254
|
+
problems.push({
|
|
255
|
+
severity: 'error',
|
|
256
|
+
message: `tokens="{${path}}"${from} names a token, not a group: a layer takes its bindings from the tokens inside a group`,
|
|
257
|
+
...(loc ?? {}),
|
|
258
|
+
});
|
|
259
|
+
else {
|
|
260
|
+
unsolid(found, path, loc, problems);
|
|
261
|
+
(component.groups ??= {})[planKey(path, selection)] = planOf(path, found);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return problems;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The group the component line names, for the root layer. Whether it names anything was asked of
|
|
269
|
+
* the design system already (it gives the axes); a group the source cannot describe, or a path
|
|
270
|
+
* that is a token, binds nothing.
|
|
271
|
+
*/
|
|
272
|
+
export function resolveComponentGroup(
|
|
273
|
+
component: Component,
|
|
274
|
+
source: DesignSource | undefined,
|
|
275
|
+
loc: Loc | undefined,
|
|
276
|
+
selection: Record<string, string> = {},
|
|
277
|
+
): Problem[] {
|
|
278
|
+
const problems: Problem[] = [];
|
|
279
|
+
const path = component.tokens;
|
|
280
|
+
if (path === undefined || !source?.group) return problems;
|
|
281
|
+
let found: GroupInfo | 'token' | undefined;
|
|
282
|
+
try {
|
|
283
|
+
found = source.group(path, selection);
|
|
284
|
+
} catch {
|
|
285
|
+
return problems; // said by resolveAxes
|
|
286
|
+
}
|
|
287
|
+
if (found === undefined || found === 'token') return problems;
|
|
288
|
+
unsolid(found, path, loc, problems);
|
|
289
|
+
(component.groups ??= {})[planKey(path, selection)] = planOf(path, found);
|
|
290
|
+
return problems;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Collects what inference says about each layer, once per key and layer, however many members repeat it:
|
|
295
|
+
* pass `seen` to `toMember`. Run in the reader, where the plans are made, and not in the engine.
|
|
296
|
+
*/
|
|
297
|
+
export function sayings(): { notes: Set<string>; warnings: Set<string>; seen: (layer: Layer, group: string, laid: Laid) => void } {
|
|
298
|
+
const notes = new Set<string>();
|
|
299
|
+
const warnings = new Set<string>();
|
|
300
|
+
const seen = (layer: Layer, group: string, laid: Laid): void => {
|
|
301
|
+
const where = `${layer.kind} "${layer.address}" tokens="{${group}}": `;
|
|
302
|
+
for (const line of laid.notes ?? []) notes.add(where + line);
|
|
303
|
+
for (const line of laid.warnings ?? []) warnings.add(where + line);
|
|
304
|
+
};
|
|
305
|
+
return { notes, warnings, seen };
|
|
306
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A component file, read, validated and expanded.
|
|
3
|
+
*
|
|
4
|
+
* Pure: no Figma API and no Node API, so the plugin runs it on the text of a file
|
|
5
|
+
* whichever way the text came. The file format is owned by
|
|
6
|
+
* `reference.md`, *Component files*; the design is `specs.md`, *Components from
|
|
7
|
+
* files*.
|
|
8
|
+
*
|
|
9
|
+
* The file is a kdlet file. kdlet reads it; this reads the five words that are
|
|
10
|
+
* Toghen's; kdlet evaluates the document once per combination of the variant axes;
|
|
11
|
+
* and Toghen checks each tree it gets back.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Document } from 'kdlet';
|
|
15
|
+
import { checkLeftOutModes, resolveAxes, selectionOf } from './axes.js';
|
|
16
|
+
import type { DesignSource } from './axes.js';
|
|
17
|
+
import { checkPins, checkRoots } from './check.js';
|
|
18
|
+
import { groupsUsed, resolveComponentGroup, resolveGroups, sayings } from './groups.js';
|
|
19
|
+
import { evaluateMembers, toMember } from './members.js';
|
|
20
|
+
import { isArtboardText, readArtboardFile } from './artboard.js';
|
|
21
|
+
import { readFile } from './read.js';
|
|
22
|
+
import { validateComponent, validateMember, validateMembers } from './validate.js';
|
|
23
|
+
import type { Component, Member, Problem } from './model.js';
|
|
24
|
+
|
|
25
|
+
export type * from './model.js';
|
|
26
|
+
export { FIELDS, LAYER_KINDS, PROPERTY_TYPES } from './fields.js';
|
|
27
|
+
export type { FieldSpec, PropertyType, ValueKind } from './fields.js';
|
|
28
|
+
export { combinations, evaluateMembers, toArtboardLayer, toLayer, toMember } from './members.js';
|
|
29
|
+
export { ARTBOARD_WORD, isArtboardText, readArtboardFile } from './artboard.js';
|
|
30
|
+
export { BLUEPRINT_WORDS, blueprintNeeds, isBlueprintFile, readBlueprint } from './blueprint.js';
|
|
31
|
+
export type { Blueprint, BlueprintNeeds, BlueprintTokens, ComponentNeed } from './blueprint.js';
|
|
32
|
+
export { expandSegments, INPUT_SEGMENT, pinsOf, resolveAxes, viaAxes } from './axes.js';
|
|
33
|
+
export type { DesignSource } from './axes.js';
|
|
34
|
+
export { applyBindings } from './bindings.js';
|
|
35
|
+
export type { Evaluated } from './members.js';
|
|
36
|
+
export type * from './steps.js';
|
|
37
|
+
export { diff, memberBoard, MEMBER_GAP } from './diff.js';
|
|
38
|
+
export { SET_GAP, describeStack, restack } from './stack.js';
|
|
39
|
+
export type { Plan, TopLevel } from './stack.js';
|
|
40
|
+
export type { Board, LayerSnapshot, MemberCell, MemberSnapshot, PropertySnapshot, SetSnapshot, Snapshot, UnstampedNode } from './diff.js';
|
|
41
|
+
|
|
42
|
+
export interface ComponentFile {
|
|
43
|
+
/** True for an artboard file: `component` then holds only its name, and `members` its one frame. */
|
|
44
|
+
artboard?: true;
|
|
45
|
+
/** Absent only when the file holds no usable `component` node (or `artboard` node). */
|
|
46
|
+
component?: Component;
|
|
47
|
+
/** What kdlet evaluates, once per combination: the file without the words that are Toghen's, as plain JSON. */
|
|
48
|
+
document?: Document;
|
|
49
|
+
/** Empty when the component has errors: a file that is wrong is not expanded. */
|
|
50
|
+
members: Member[];
|
|
51
|
+
/** Errors and warnings, in file order. Nothing is built while any is an error. */
|
|
52
|
+
problems: Problem[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const position = (problem: Problem): number => (problem.line === undefined ? Infinity : problem.line * 100000 + (problem.column ?? 0));
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Problems found member by member, as the file's: one found in every member is one
|
|
59
|
+
* problem, and a member is named only when it is found in some and not all.
|
|
60
|
+
*/
|
|
61
|
+
function perMember(found: { member: string; problems: Problem[] }[]): Problem[] {
|
|
62
|
+
const groups = new Map<string, { problem: Problem; members: string[] }>();
|
|
63
|
+
for (const { member, problems } of found) {
|
|
64
|
+
for (const problem of problems) {
|
|
65
|
+
const key = `${problem.severity}:${problem.line}:${problem.column}:${problem.message}`;
|
|
66
|
+
const group = groups.get(key) ?? { problem, members: [] };
|
|
67
|
+
group.members.push(member);
|
|
68
|
+
groups.set(key, group);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const out: Problem[] = [];
|
|
72
|
+
for (const { problem, members } of groups.values()) {
|
|
73
|
+
const { member: _member, ...bare } = problem;
|
|
74
|
+
if (members.length === found.length) out.push(bare);
|
|
75
|
+
else for (const member of members) out.push({ ...bare, member });
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Reads, validates and expands one component file, in one pass.
|
|
82
|
+
*
|
|
83
|
+
* Every problem in the file is reported, not the first. The members are evaluated
|
|
84
|
+
* and checked only when the component itself has no errors, since a member of a
|
|
85
|
+
* broken component would repeat the same errors once per combination.
|
|
86
|
+
*
|
|
87
|
+
* A file with an `artboard` node at the top is an artboard file and is read as one (`artboard.ts`), against the
|
|
88
|
+
* `inputs` given; a component file ignores them.
|
|
89
|
+
*/
|
|
90
|
+
export function readComponentFile(text: string, source?: DesignSource, inputs?: Record<string, unknown>): ComponentFile {
|
|
91
|
+
if (isArtboardText(text)) return readArtboardFile(text, inputs);
|
|
92
|
+
const read = readFile(text);
|
|
93
|
+
if (!read.component) return { members: [], problems: read.problems };
|
|
94
|
+
const { component, document } = read;
|
|
95
|
+
|
|
96
|
+
// An axis that takes its values from a modifier, and the axes `tokens=` brings in, need the
|
|
97
|
+
// design system. Until they are known the axes are not, so a file whose axes cannot be filled
|
|
98
|
+
// is not checked against them: it would be told about guards and exclusions that are not wrong.
|
|
99
|
+
const axes = resolveAxes(component, source, read.componentLoc);
|
|
100
|
+
// The same goes for the token groups: what `tokens=` infers from is stored on the component, for the script and the plugin, which have no design system.
|
|
101
|
+
const problems = [
|
|
102
|
+
...read.problems,
|
|
103
|
+
...axes,
|
|
104
|
+
...(axes.length > 0 ? [] : [...validateComponent(component, document), ...resolveComponentGroup(component, source, read.componentLoc)]),
|
|
105
|
+
];
|
|
106
|
+
let members: Member[] = [];
|
|
107
|
+
if (!problems.some((problem) => problem.severity === 'error')) {
|
|
108
|
+
const evaluated = evaluateMembers(component, document);
|
|
109
|
+
const at = read.componentLoc ?? { line: 1, column: 1 };
|
|
110
|
+
const said = sayings();
|
|
111
|
+
// A tree kdlet had problems with is not checked further: what is wrong in it would be said twice.
|
|
112
|
+
const found = evaluated.map((e) => {
|
|
113
|
+
const own = [...e.problems];
|
|
114
|
+
if (own.length === 0) own.push(...checkRoots(e.roots, component.name, at), ...checkPins(e.roots, component));
|
|
115
|
+
if (own.length === 0) {
|
|
116
|
+
// A member that selects part groups binds their tokens: its plans are made for that selection.
|
|
117
|
+
const selection = selectionOf(component, e.combination);
|
|
118
|
+
own.push(...resolveGroups(component, groupsUsed(e.roots, e.combination), source, selection));
|
|
119
|
+
if (Object.keys(selection).length > 0) own.push(...resolveComponentGroup(component, source, read.componentLoc, selection));
|
|
120
|
+
}
|
|
121
|
+
const member = own.length === 0 ? toMember(e, component, said.seen) : undefined;
|
|
122
|
+
if (member) own.push(...checkLeftOutModes(member.tree, source));
|
|
123
|
+
if (member) own.push(...validateMember(component, member));
|
|
124
|
+
return { name: e.name, problems: own, member };
|
|
125
|
+
});
|
|
126
|
+
problems.push(...perMember(found.map((f) => ({ member: f.name, problems: f.problems }))));
|
|
127
|
+
members = [];
|
|
128
|
+
for (const f of found) if (f.member) members.push(f.member);
|
|
129
|
+
if (members.length === evaluated.length) problems.push(...validateMembers(component, members));
|
|
130
|
+
if (problems.some((problem) => problem.severity === 'error')) members = [];
|
|
131
|
+
// What inference skipped or could not bind travels with the component: the run's report says it, and the engine has no design system to work it out from.
|
|
132
|
+
if (said.notes.size > 0) component.notes = [...said.notes];
|
|
133
|
+
if (said.warnings.size > 0) component.warnings = [...said.warnings];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A stable sort: problems on one line keep the order they were found in.
|
|
137
|
+
const ordered = problems.map((problem, index) => ({ problem, index }));
|
|
138
|
+
ordered.sort((a, b) => position(a.problem) - position(b.problem) || a.index - b.index);
|
|
139
|
+
return { component, document, members, problems: ordered.map((entry) => entry.problem) };
|
|
140
|
+
}
|