@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,548 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model to problems: what needs more than one node to check.
|
|
3
|
+
*
|
|
4
|
+
* `check.ts` refuses what is wrong with a node and its fields on their own; this
|
|
5
|
+
* refuses what is only wrong in relation to something else: a property nothing
|
|
6
|
+
* wires, a grid child with no cell, a guard that compares a name that is no
|
|
7
|
+
* variant axis. It runs once over the component as written (its properties, its
|
|
8
|
+
* exclusions and the guards in its document), once over each member, because a
|
|
9
|
+
* `when` can break what the other members got right, and once over all the
|
|
10
|
+
* members together, for what only the whole set can say.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Document, Item } from 'kdlet';
|
|
14
|
+
import type { Condition, Operand } from 'kdlet-guards';
|
|
15
|
+
import { FIELDS, GRID_CHILD_FIELDS, GRID_ONLY_FIELDS, NOT_IN_GRID_FIELDS, WRAP_FIELDS, isStructured } from './fields.js';
|
|
16
|
+
import { memberName } from './members.js';
|
|
17
|
+
import type { Component, FieldValue, Layer, Loc, Member, Problem, Property, Value } from './model.js';
|
|
18
|
+
|
|
19
|
+
type Wirable = Property & { type: 'BOOLEAN' | 'TEXT' | 'INSTANCE_SWAP' };
|
|
20
|
+
|
|
21
|
+
const isWirable = (property: Property): property is Wirable =>
|
|
22
|
+
property.type === 'BOOLEAN' || property.type === 'TEXT' || property.type === 'INSTANCE_SWAP';
|
|
23
|
+
|
|
24
|
+
function walk(layer: Layer, visit: (layer: Layer, parent: Layer | undefined) => void, parent?: Layer): void {
|
|
25
|
+
visit(layer, parent);
|
|
26
|
+
for (const child of layer.children) walk(child, visit, layer);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const plain = (layer: Layer, field: string): string | number | boolean | null | undefined => {
|
|
30
|
+
const value = layer.fields[field] as Value | undefined;
|
|
31
|
+
return value && value.kind === 'plain' ? value.value : undefined;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const describe = (layer: Layer): string => `${layer.kind} "${layer.address}"`;
|
|
35
|
+
|
|
36
|
+
/** Where a problem with one field of a layer should point: the field, else the layer. */
|
|
37
|
+
const fieldAt = (layer: Layer, field: string): Loc | undefined => layer.fieldLoc?.[field] ?? layer.loc;
|
|
38
|
+
|
|
39
|
+
export class Report {
|
|
40
|
+
readonly problems: Problem[] = [];
|
|
41
|
+
constructor(private readonly member?: string) {}
|
|
42
|
+
|
|
43
|
+
error(message: string, loc?: Loc): void {
|
|
44
|
+
this.add('error', message, loc);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
warning(message: string, loc?: Loc): void {
|
|
48
|
+
this.add('warning', message, loc);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private add(severity: Problem['severity'], message: string, loc?: Loc): void {
|
|
52
|
+
this.problems.push({ severity, message, ...(loc ?? {}), ...(this.member !== undefined ? { member: this.member } : {}) });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Layout consistency for one layer and its children.
|
|
58
|
+
*
|
|
59
|
+
* A grid and a flow layout take disjoint fields, and Figma throws when a field
|
|
60
|
+
* meets the wrong layout, so the file is refused before anything is built.
|
|
61
|
+
*/
|
|
62
|
+
export function checkLayout(layer: Layer, parent: Layer | undefined, report: Report): void {
|
|
63
|
+
const mode = plain(layer, 'layoutMode') ?? 'NONE';
|
|
64
|
+
const what = describe(layer);
|
|
65
|
+
|
|
66
|
+
if (layer.kind === 'SLOT' && mode === 'GRID') {
|
|
67
|
+
report.error(`${what} cannot be a GRID: a slot may sit in a grid but may not be one`, fieldAt(layer, 'layoutMode'));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!parent && 'grid' in layer.fields) {
|
|
72
|
+
report.error(`${what} is the root and has no parent grid to place it in`, fieldAt(layer, 'grid'));
|
|
73
|
+
}
|
|
74
|
+
if (!parent) {
|
|
75
|
+
for (const field of ['layoutSizingHorizontal', 'layoutSizingVertical']) {
|
|
76
|
+
if (plain(layer, field) === 'FILL') {
|
|
77
|
+
report.error(`${what} is the root and has no parent to fill; ${field} can be FIXED or HUG`, fieldAt(layer, field));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A size bound to a token is a fixed size: beside a sizing that lets the layer size itself, the file says two things,
|
|
83
|
+
// and the target settles on one of them, so every run writes the other again.
|
|
84
|
+
for (const [size, sizing] of [
|
|
85
|
+
['width', 'layoutSizingHorizontal'],
|
|
86
|
+
['height', 'layoutSizingVertical'],
|
|
87
|
+
] as const) {
|
|
88
|
+
const bound = layer.fields[size] as Value | undefined;
|
|
89
|
+
if (bound?.kind !== 'token') continue;
|
|
90
|
+
const said = plain(layer, sizing);
|
|
91
|
+
const resize = plain(layer, 'textAutoResize');
|
|
92
|
+
const auto =
|
|
93
|
+
said === 'HUG' || said === 'FILL'
|
|
94
|
+
? `${sizing}=${said}`
|
|
95
|
+
: resize === 'WIDTH_AND_HEIGHT' || (size === 'height' && resize === 'HEIGHT')
|
|
96
|
+
? `textAutoResize=${resize}`
|
|
97
|
+
: undefined;
|
|
98
|
+
if (auto === undefined) continue;
|
|
99
|
+
const keep = auto.startsWith('textAutoResize') ? `write textAutoResize=${size === 'height' ? 'NONE' : 'HEIGHT'}` : `drop ${said}`;
|
|
100
|
+
report.error(
|
|
101
|
+
`${what}: "${size}" is bound to {${bound.path}} while ${auto}: a bound ${size} is a fixed size; write ${auto} and drop the ${size} token, or keep the token and ${keep}`,
|
|
102
|
+
fieldAt(layer, size),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (mode === 'GRID') {
|
|
107
|
+
for (const field of ['gridRowSizes', 'gridColumnSizes']) {
|
|
108
|
+
if (!(field in layer.fields)) report.error(`${what} is a GRID and needs ${field}`, layer.loc);
|
|
109
|
+
}
|
|
110
|
+
for (const field of NOT_IN_GRID_FIELDS) {
|
|
111
|
+
if (field in layer.fields) {
|
|
112
|
+
report.error(
|
|
113
|
+
`${what} is a GRID, which has no ${field}; a grid spaces itself with gridRowGap and gridColumnGap`,
|
|
114
|
+
fieldAt(layer, field),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const [sizing, tracks, axis] of [
|
|
119
|
+
['layoutSizingHorizontal', 'gridColumnSizes', 'width'],
|
|
120
|
+
['layoutSizingVertical', 'gridRowSizes', 'height'],
|
|
121
|
+
] as const) {
|
|
122
|
+
const list = layer.fields[tracks] as { type: string }[] | undefined;
|
|
123
|
+
if (plain(layer, sizing) === 'HUG' && list?.some((track) => track.type === 'FLEX')) {
|
|
124
|
+
report.error(
|
|
125
|
+
`${what} hugs its ${axis}, so ${tracks} cannot hold a FLEX track: a FLEX track is invalid on an axis the grid hugs`,
|
|
126
|
+
fieldAt(layer, tracks),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const child of layer.children) {
|
|
131
|
+
if (!('grid' in child.fields)) {
|
|
132
|
+
report.error(`${describe(child)} sits in the GRID "${layer.address}" and needs \`grid row= column=\``, child.loc);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
for (const field of GRID_ONLY_FIELDS) {
|
|
137
|
+
if (field in layer.fields) {
|
|
138
|
+
report.error(`${what} is not a GRID (its layoutMode is ${mode}) and cannot have ${field}`, fieldAt(layer, field));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const child of layer.children) {
|
|
142
|
+
if ('grid' in child.fields) {
|
|
143
|
+
report.error(`${describe(child)} has \`grid\`, but its parent "${layer.address}" is not a GRID`, fieldAt(child, 'grid'));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Wrap, a grid child's alignment, and position: each means something only beside a layout, its own or its parent's.
|
|
149
|
+
if (mode !== 'HORIZONTAL' && mode !== 'VERTICAL') {
|
|
150
|
+
for (const field of WRAP_FIELDS) {
|
|
151
|
+
if (field in layer.fields)
|
|
152
|
+
report.error(
|
|
153
|
+
`${what} has ${field}, which wraps a flow; its layoutMode is ${mode}, and it needs HORIZONTAL or VERTICAL`,
|
|
154
|
+
fieldAt(layer, field),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const parentMode = parent ? (plain(parent, 'layoutMode') ?? 'NONE') : undefined;
|
|
159
|
+
if (parentMode !== 'GRID') {
|
|
160
|
+
for (const field of GRID_CHILD_FIELDS) {
|
|
161
|
+
if (field in layer.fields)
|
|
162
|
+
report.error(
|
|
163
|
+
`${what} has ${field}, which aligns a child in its grid cell, but ${parent ? `its parent "${parent.address}" is not a GRID` : 'it is the root'}`,
|
|
164
|
+
fieldAt(layer, field),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if ('layoutPositioning' in layer.fields && (parentMode === undefined || parentMode === 'NONE')) {
|
|
169
|
+
report.error(
|
|
170
|
+
`${what} has layoutPositioning, which only a child of an auto-layout frame takes, but ${parent ? `its parent "${parent.address}" has no layout` : 'it is the root'}`,
|
|
171
|
+
fieldAt(layer, 'layoutPositioning'),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
for (const field of ['x', 'y']) {
|
|
175
|
+
if (!(field in layer.fields)) continue;
|
|
176
|
+
// The root is placed by Toghen: a member in its set, an artboard on the board.
|
|
177
|
+
if (!parent) report.error(`${what} is the root, which Toghen places on the page, so it takes no ${field}`, fieldAt(layer, field));
|
|
178
|
+
else if (parentMode !== 'NONE' && plain(layer, 'layoutPositioning') !== 'ABSOLUTE') {
|
|
179
|
+
report.error(
|
|
180
|
+
`${what} has ${field}, but its parent "${parent.address}" places it (layoutMode ${parentMode}); write layoutPositioning=ABSOLUTE to place it yourself`,
|
|
181
|
+
fieldAt(layer, field),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Every `(property)` a layer's fields wire, as field and property name. */
|
|
188
|
+
function wiresOf(fields: Record<string, FieldValue>): { field: string; name: string }[] {
|
|
189
|
+
const wires: { field: string; name: string }[] = [];
|
|
190
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
191
|
+
const spec = Object.prototype.hasOwnProperty.call(FIELDS, field) ? FIELDS[field] : undefined;
|
|
192
|
+
if (spec && !isStructured(spec) && (value as Value).kind === 'property') {
|
|
193
|
+
wires.push({ field, name: (value as { name: string }).name });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return wires;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The wirable properties some layer of the member wires to a property of the right type. */
|
|
200
|
+
function wiredIn(member: Member, byName: Map<string, Property>): Set<string> {
|
|
201
|
+
const wired = new Set<string>();
|
|
202
|
+
walk(member.tree, (layer, parent) => {
|
|
203
|
+
if (parent === undefined) return;
|
|
204
|
+
for (const { field, name } of wiresOf(layer.fields)) {
|
|
205
|
+
if (byName.get(name)?.type === FIELDS[field].wires) wired.add(name);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
return wired;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The layers some member wires to the property, as `address` and `field`, for telling a member that
|
|
213
|
+
* fixes one of them on purpose (`(literal)`) from one that lost the wire by accident.
|
|
214
|
+
*/
|
|
215
|
+
function sitesOf(members: Member[]): Map<string, { address: string; field: string }[]> {
|
|
216
|
+
const sites = new Map<string, { address: string; field: string }[]>();
|
|
217
|
+
for (const member of members) {
|
|
218
|
+
walk(member.tree, (layer, parent) => {
|
|
219
|
+
if (parent === undefined) return;
|
|
220
|
+
for (const { field, name } of wiresOf(layer.fields)) {
|
|
221
|
+
const list = sites.get(name) ?? [];
|
|
222
|
+
if (!list.some((site) => site.address === layer.address && site.field === field))
|
|
223
|
+
sites.set(name, [...list, { address: layer.address, field }]);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return sites;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** True when the member writes `(literal)` on a field that other members wire to the property. */
|
|
231
|
+
function fixedOnPurpose(member: Member, sites: { address: string; field: string }[]): boolean {
|
|
232
|
+
let fixed = false;
|
|
233
|
+
walk(member.tree, (layer) => {
|
|
234
|
+
for (const site of sites)
|
|
235
|
+
if (layer.address === site.address && (layer.fields[site.field] as Value | undefined)?.kind === 'literal') fixed = true;
|
|
236
|
+
});
|
|
237
|
+
return fixed;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The variant axes by name, with their values. */
|
|
241
|
+
class Axes extends Map<string, string[]> {
|
|
242
|
+
/** The contexts an axis taken from a modifier leaves to CSS (`figma.include`), by axis. */
|
|
243
|
+
private readonly leftOut = new Map<string, { modifier: string; contexts: string[] }>();
|
|
244
|
+
|
|
245
|
+
constructor(component: Component) {
|
|
246
|
+
super();
|
|
247
|
+
for (const property of component.properties) {
|
|
248
|
+
if (property.type !== 'VARIANT') continue;
|
|
249
|
+
this.set(property.name, property.values);
|
|
250
|
+
if (property.modifier !== undefined && property.leftOut)
|
|
251
|
+
this.leftOut.set(property.name, { modifier: property.modifier, contexts: property.leftOut });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Why a value is none, when it is a context Figma does not get: said after the list of values. */
|
|
256
|
+
note(axis: string, value: unknown): string {
|
|
257
|
+
const left = this.leftOut.get(axis);
|
|
258
|
+
return left && typeof value === 'string' && left.contexts.includes(value)
|
|
259
|
+
? `; "${value}" is a context of the modifier "${left.modifier}" that Figma does not get, since its figma.include leaves it out`
|
|
260
|
+
: '';
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const axesOf = (component: Component): Axes => new Axes(component);
|
|
265
|
+
|
|
266
|
+
const propertiesByName = (component: Component): Map<string, Property> => {
|
|
267
|
+
const byName = new Map<string, Property>();
|
|
268
|
+
for (const property of component.properties) if (!byName.has(property.name)) byName.set(property.name, property);
|
|
269
|
+
return byName;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// The component: properties, exclusions, and the guards of the document
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
function checkGuardWord(word: Condition, guard: string, axes: Axes, report: Report): void {
|
|
277
|
+
const axisOf = (operand: Operand, at: Loc | undefined): string | undefined => {
|
|
278
|
+
if (typeof operand !== 'object' || operand === null) return undefined;
|
|
279
|
+
const { ref } = operand;
|
|
280
|
+
if (ref.from !== 'input') return undefined;
|
|
281
|
+
if (!axes.has(ref.field)) {
|
|
282
|
+
report.error(
|
|
283
|
+
`guard "${guard}" reads (input)${ref.field}, which is not a VARIANT axis; the axes are the only inputs a component file has`,
|
|
284
|
+
operand.loc ?? at,
|
|
285
|
+
);
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
return ref.field;
|
|
289
|
+
};
|
|
290
|
+
if ('is' in word) {
|
|
291
|
+
const axis = axisOf(word.is, word.loc);
|
|
292
|
+
for (const value of word.values) {
|
|
293
|
+
if (typeof value === 'object' && value !== null) {
|
|
294
|
+
axisOf(value, word.loc);
|
|
295
|
+
} else if (axis !== undefined) {
|
|
296
|
+
const known = axes.get(axis) as string[];
|
|
297
|
+
if (typeof value !== 'string' || !known.includes(value)) {
|
|
298
|
+
report.error(
|
|
299
|
+
`guard "${guard}": ${JSON.stringify(value)} is not one of ${axis}'s values (${known.join(', ')})${axes.note(axis, value)}`,
|
|
300
|
+
word.loc,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
} else if ('has' in word) {
|
|
306
|
+
axisOf(word.has, word.loc);
|
|
307
|
+
} else if ('all' in word) {
|
|
308
|
+
for (const inner of word.all) checkGuardWord(inner, guard, axes, report);
|
|
309
|
+
} else if ('any' in word) {
|
|
310
|
+
for (const inner of word.any) checkGuardWord(inner, guard, axes, report);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** What the document says that the axes must agree with, and what a component file cannot use of the language. */
|
|
315
|
+
function checkDocument(items: Item[], axes: Axes, report: Report, inDefinition = false): void {
|
|
316
|
+
for (const item of items) {
|
|
317
|
+
if (item.kind === 'each') {
|
|
318
|
+
report.error('"each" is not built for component files: nothing supplies a list to repeat over', item.loc);
|
|
319
|
+
} else if (item.kind === 'guard') {
|
|
320
|
+
if (item.body === undefined) {
|
|
321
|
+
report.error(
|
|
322
|
+
`guard "${item.name}" has no body, which marks a guard the host supplies; a component file has no host to supply one`,
|
|
323
|
+
item.loc,
|
|
324
|
+
);
|
|
325
|
+
} else if (!inDefinition) {
|
|
326
|
+
// Inside a definition `(input)` reads a parameter, not an axis, so only the file's own guards are held to the axes.
|
|
327
|
+
for (const word of item.body) checkGuardWord(word, item.name, axes, report);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if ('children' in item) checkDocument(item.children, axes, report, inDefinition || item.kind === 'define');
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The component as written: its properties, its exclusions, and the guards of its
|
|
336
|
+
* document held against the variant axes.
|
|
337
|
+
*
|
|
338
|
+
* Errors unless marked; every message names the thing in the file's words and
|
|
339
|
+
* carries the line and column of the node it is about.
|
|
340
|
+
*/
|
|
341
|
+
export function validateComponent(component: Component, document: Document): Problem[] {
|
|
342
|
+
const report = new Report();
|
|
343
|
+
const axes = axesOf(component);
|
|
344
|
+
|
|
345
|
+
const byName = new Map<string, Property>();
|
|
346
|
+
for (const property of component.properties) {
|
|
347
|
+
const first = byName.get(property.name);
|
|
348
|
+
if (first) {
|
|
349
|
+
report.error(
|
|
350
|
+
`${property.type} "${property.name}" repeats the name of ${first.type} "${first.name}"${first.loc ? ` (line ${first.loc.line})` : ''}; ` +
|
|
351
|
+
`properties share one namespace, and Figma renames one of two that collide, silently`,
|
|
352
|
+
property.loc,
|
|
353
|
+
);
|
|
354
|
+
} else {
|
|
355
|
+
byName.set(property.name, property);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
checkDocument(document.nodes, axes, report);
|
|
360
|
+
|
|
361
|
+
// `across=` names axes of the board; the axes are known only now, since some come from the design system.
|
|
362
|
+
for (const name of component.across ?? []) {
|
|
363
|
+
if (!axes.has(name)) report.error(`component: across names "${name}", which is not a VARIANT axis`, component.acrossLoc);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
for (const exclusion of component.exclude) {
|
|
367
|
+
for (const [axis, value] of Object.entries(exclusion.combination)) {
|
|
368
|
+
const known = axes.get(axis);
|
|
369
|
+
if (!known) report.error(`exclude names "${axis}", which is not a VARIANT axis`, exclusion.loc);
|
|
370
|
+
else if (!known.includes(value))
|
|
371
|
+
report.error(`exclude ${axis}: "${value}" is not one of its values (${known.join(', ')})${axes.note(axis, value)}`, exclusion.loc);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// `include` names combinations by the same rule as `exclude`.
|
|
375
|
+
for (const inclusion of component.include ?? []) {
|
|
376
|
+
for (const [axis, value] of Object.entries(inclusion.combination)) {
|
|
377
|
+
const known = axes.get(axis);
|
|
378
|
+
if (!known) report.error(`include names "${axis}", which is not a VARIANT axis`, inclusion.loc);
|
|
379
|
+
else if (!known.includes(value))
|
|
380
|
+
report.error(`include ${axis}: "${value}" is not one of its values (${known.join(', ')})${axes.note(axis, value)}`, inclusion.loc);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (axes.size > 0) {
|
|
384
|
+
const defaults: Record<string, string> = {};
|
|
385
|
+
for (const [axis, values] of axes) defaults[axis] = values[0];
|
|
386
|
+
for (const exclusion of component.exclude) {
|
|
387
|
+
if (Object.entries(exclusion.combination).every(([axis, value]) => defaults[axis] === value)) {
|
|
388
|
+
report.error(
|
|
389
|
+
`exclude removes the default member (${memberName(defaults)}); the first value of every axis is the default and is always built`,
|
|
390
|
+
exclusion.loc,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// With `include`, only what a line matches is built, and the default member is always built: some line must match it.
|
|
395
|
+
const included = component.include;
|
|
396
|
+
if (
|
|
397
|
+
included !== undefined &&
|
|
398
|
+
included.length > 0 &&
|
|
399
|
+
!included.some((line) => Object.entries(line.combination).every(([axis, value]) => defaults[axis] === value))
|
|
400
|
+
) {
|
|
401
|
+
report.error(
|
|
402
|
+
`include does not include the default member (${memberName(defaults)}); the first value of every axis is the default and is always built, so some include line must match it`,
|
|
403
|
+
included[0].loc,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return report.problems;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// One member
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* One member's layers: names, wiring, slots and layout.
|
|
417
|
+
*
|
|
418
|
+
* The same member is evaluated again for every combination, so a problem found in
|
|
419
|
+
* every member is found once per member. The caller reports it once, and names a
|
|
420
|
+
* member only when the problem is found in some and not all.
|
|
421
|
+
*/
|
|
422
|
+
export function validateMember(component: Component, member: Member): Problem[] {
|
|
423
|
+
const report = new Report(member.name);
|
|
424
|
+
const byName = propertiesByName(component);
|
|
425
|
+
|
|
426
|
+
const byAddress = new Map<string, Layer>();
|
|
427
|
+
const slotLayers: Layer[] = [];
|
|
428
|
+
walk(member.tree, (layer, parent) => {
|
|
429
|
+
if (layer.kind === 'SLOT') slotLayers.push(layer);
|
|
430
|
+
const first = byAddress.get(layer.address);
|
|
431
|
+
if (first) {
|
|
432
|
+
// Two SLOT layers for one property are reported once, as that.
|
|
433
|
+
if (!(first.kind === 'SLOT' && layer.kind === 'SLOT')) {
|
|
434
|
+
report.error(
|
|
435
|
+
`${describe(layer)} repeats the name of ${describe(first)}${first.loc ? ` (line ${first.loc.line})` : ''}; ` +
|
|
436
|
+
`a layer's name is its address, so names must be unique in the component`,
|
|
437
|
+
layer.loc,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
} else {
|
|
441
|
+
byAddress.set(layer.address, layer);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
for (const { field, name } of wiresOf(layer.fields)) {
|
|
445
|
+
const wants = FIELDS[field].wires;
|
|
446
|
+
const property = byName.get(name);
|
|
447
|
+
const loc = fieldAt(layer, field);
|
|
448
|
+
if (parent === undefined) {
|
|
449
|
+
// Figma lets a property reference only a layer inside the component, and the root layer is the component.
|
|
450
|
+
report.error(
|
|
451
|
+
`"${field}" of ${describe(layer)} is wired to (property)"${name}", but the root layer is the component itself; wire a layer inside it`,
|
|
452
|
+
loc,
|
|
453
|
+
);
|
|
454
|
+
} else if (!property) {
|
|
455
|
+
report.error(`"${field}" of ${describe(layer)} is wired to (property)"${name}", which is not a declared property`, loc);
|
|
456
|
+
} else if (property.type !== wants) {
|
|
457
|
+
report.error(`"${field}" of ${describe(layer)} wires to a ${wants} property, but "${name}" is a ${property.type}`, loc);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
checkLayout(layer, parent, report);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// Slots: each SLOT property has exactly one SLOT layer, and the reverse.
|
|
464
|
+
for (const property of component.properties) {
|
|
465
|
+
if (property.type !== 'SLOT') continue;
|
|
466
|
+
const layers = slotLayers.filter((layer) => layer.address === property.name);
|
|
467
|
+
if (layers.length === 0) report.error(`SLOT property "${property.name}" has no SLOT layer`, property.loc);
|
|
468
|
+
if (layers.length > 1) {
|
|
469
|
+
report.error(`SLOT property "${property.name}" has ${layers.length} SLOT layers; it has exactly one`, layers[1].loc);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
for (const layer of slotLayers) {
|
|
473
|
+
if (byName.get(layer.address)?.type !== 'SLOT') {
|
|
474
|
+
report.error(`${describe(layer)} names no SLOT property; a slot layer is named for the SLOT property it is`, layer.loc);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return report.problems;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* The members together: a property no member wires is an error; one that some
|
|
482
|
+
* member leaves wired to nothing is a warning for that member.
|
|
483
|
+
*
|
|
484
|
+
* The warning is not an error because it is sometimes what a `when` means (an
|
|
485
|
+
* aside that a single-column layout does not have), and it is worth saying
|
|
486
|
+
* because it is also exactly how a property goes dead by accident. A member that
|
|
487
|
+
* writes `(literal)` on the field says it is meant (`characters=(literal)"Ready"`),
|
|
488
|
+
* and gets no warning.
|
|
489
|
+
*/
|
|
490
|
+
export function validateMembers(component: Component, members: Member[]): Problem[] {
|
|
491
|
+
const problems: Problem[] = [];
|
|
492
|
+
const byName = propertiesByName(component);
|
|
493
|
+
const wired = members.map((member) => ({ member, names: wiredIn(member, byName) }));
|
|
494
|
+
const sites = sitesOf(members);
|
|
495
|
+
for (const property of component.properties) {
|
|
496
|
+
if (!isWirable(property)) continue;
|
|
497
|
+
if (!wired.some(({ names }) => names.has(property.name))) {
|
|
498
|
+
const by = { BOOLEAN: 'visible', TEXT: 'characters', INSTANCE_SWAP: 'component' }[property.type];
|
|
499
|
+
problems.push({
|
|
500
|
+
severity: 'error',
|
|
501
|
+
message: `${property.type} property "${property.name}" is wired to no layer in any member; a layer wires it with ${by}=(property)"${property.name}"`,
|
|
502
|
+
...(property.loc ?? {}),
|
|
503
|
+
});
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
for (const { member, names } of wired) {
|
|
507
|
+
if (!names.has(property.name) && !fixedOnPurpose(member, sites.get(property.name) ?? [])) {
|
|
508
|
+
problems.push({
|
|
509
|
+
severity: 'warning',
|
|
510
|
+
message: `${property.type} property "${property.name}" is wired to nothing in this member`,
|
|
511
|
+
...(property.loc ?? {}),
|
|
512
|
+
member: member.name,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return problems;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
// An artboard
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* An artboard's tree: addresses and layout.
|
|
526
|
+
*
|
|
527
|
+
* An address is the layer's name with the keys of the `each`es around it, and it is what the next run finds the
|
|
528
|
+
* layer by, so two layers may not share one. The properties and slots of an instance are held against its
|
|
529
|
+
* component in Figma, where the component is known (`main/components/resolve.ts`).
|
|
530
|
+
*/
|
|
531
|
+
export function validateArtboard(root: Layer): Problem[] {
|
|
532
|
+
const report = new Report();
|
|
533
|
+
const seen = new Map<string, Layer>();
|
|
534
|
+
walk(root, (layer, parent) => {
|
|
535
|
+
const first = seen.get(layer.address);
|
|
536
|
+
if (first) {
|
|
537
|
+
report.error(
|
|
538
|
+
`${describe(layer)} repeats the address of ${describe(first)}${first.loc ? ` (line ${first.loc.line})` : ''}; ` +
|
|
539
|
+
`a layer's name and the keys of the each around it are its address, so addresses must be unique in the artboard (a definition called twice takes its layers' names from a parameter)`,
|
|
540
|
+
layer.loc,
|
|
541
|
+
);
|
|
542
|
+
} else {
|
|
543
|
+
seen.set(layer.address, layer);
|
|
544
|
+
}
|
|
545
|
+
checkLayout(layer, parent, report);
|
|
546
|
+
});
|
|
547
|
+
return report.problems;
|
|
548
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface PropertyConvention {
|
|
2
|
+
id: string;
|
|
3
|
+
pattern: string;
|
|
4
|
+
property: FigmaNodeProperty;
|
|
5
|
+
nodeTypes: NodeType[];
|
|
6
|
+
variableType: VariableResolvedDataType;
|
|
7
|
+
description?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type FigmaNodeProperty =
|
|
11
|
+
| 'paddingLeft'
|
|
12
|
+
| 'paddingRight'
|
|
13
|
+
| 'paddingTop'
|
|
14
|
+
| 'paddingBottom'
|
|
15
|
+
| 'itemSpacing'
|
|
16
|
+
| 'cornerRadius'
|
|
17
|
+
| 'topLeftRadius'
|
|
18
|
+
| 'topRightRadius'
|
|
19
|
+
| 'bottomLeftRadius'
|
|
20
|
+
| 'bottomRightRadius'
|
|
21
|
+
| 'fills[0].color'
|
|
22
|
+
| 'strokes[0].color'
|
|
23
|
+
| 'strokeWeight'
|
|
24
|
+
| 'opacity'
|
|
25
|
+
| 'width'
|
|
26
|
+
| 'height'
|
|
27
|
+
| 'characters'
|
|
28
|
+
| string;
|
package/shared/inputs.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The data an artboard file is evaluated with: a resolver document, resolved at its defaults.
|
|
3
|
+
*
|
|
4
|
+
* The document is the design system's own format, read by `@toghen/lib` (the command line bundles its files first,
|
|
5
|
+
* as it does for the design system). Its tokens are the data: each one's resolved `$value`, nested by path, so
|
|
6
|
+
* `title` is `inputs.title` and `ds.size.md` is `inputs.ds.size.md`. A token whose `$value` is a list of records
|
|
7
|
+
* gives a list, and a reference inside a record is resolved by the library, like any other, so the data can point at
|
|
8
|
+
* the design system's tokens when the document's sources include them. Every modifier is resolved at its default context.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Resolver } from '@toghen/lib';
|
|
12
|
+
|
|
13
|
+
const COLLECTION = 'inputs';
|
|
14
|
+
|
|
15
|
+
/** The resolved tokens of a resolver document, nested by path. Throws, saying what is wrong with the document, when it cannot be resolved. */
|
|
16
|
+
export function inputsFromDocument(document: unknown): Record<string, unknown> {
|
|
17
|
+
let system: ReturnType<Resolver['resolveToDesignSystem']>;
|
|
18
|
+
try {
|
|
19
|
+
// The defaults of every modifier: `{}` selects nothing, and each modifier takes its own default (one with none is refused).
|
|
20
|
+
system = new Resolver(document as ConstructorParameters<typeof Resolver>[0]).resolveToDesignSystem({}, undefined, COLLECTION);
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw new Error(`is not a resolver document that resolves: ${(error as Error).message}`);
|
|
23
|
+
}
|
|
24
|
+
const collection = system.getCollection(COLLECTION);
|
|
25
|
+
const inputs: Record<string, unknown> = {};
|
|
26
|
+
for (const path of collection?.getAllPaths() ?? []) {
|
|
27
|
+
let value: unknown;
|
|
28
|
+
try {
|
|
29
|
+
value = system.resolveToken(`${COLLECTION}.${path}`)?.$value;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new Error(`token "${path}" does not resolve: ${(error as Error).message}`);
|
|
32
|
+
}
|
|
33
|
+
// The library keeps a token and a group of one name apart (the token wins), so a path never meets a value on the way down.
|
|
34
|
+
const names = path.split('.');
|
|
35
|
+
let into = inputs;
|
|
36
|
+
for (const name of names.slice(0, -1)) into = (into[name] ??= {}) as Record<string, unknown>;
|
|
37
|
+
into[names[names.length - 1]] = value;
|
|
38
|
+
}
|
|
39
|
+
return inputs;
|
|
40
|
+
}
|