@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,705 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toghen's own checks on the tree kdlet returns for one member.
|
|
3
|
+
*
|
|
4
|
+
* kdlet has checked what is its own: that every node is one of the four layer
|
|
5
|
+
* kinds, that every reference and tag resolves, that every `when` names a guard.
|
|
6
|
+
* What is left is Figma's: which fields a kind takes, and whether a field's value
|
|
7
|
+
* has the form that field wants. A value arrives as kdlet keeps it: plain JSON, a
|
|
8
|
+
* host tag as `{ tag, value }`, a field written as a child node as a list of its
|
|
9
|
+
* arguments or, with properties or children, as a structure.
|
|
10
|
+
*
|
|
11
|
+
* It reads token references with the core's grammar, so it is kept apart from
|
|
12
|
+
* the evaluation, which trusts a file that was checked here. A problem points at the position kdlet carried: the line of a
|
|
13
|
+
* `when` when a `when` wrote the field. A field written as a list of arguments
|
|
14
|
+
* (`fills`) has one position, its own node's, so a problem with one paint points
|
|
15
|
+
* at the node and not at the paint.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { parseReferencePath, referenceTarget } from '@toghen/lib';
|
|
19
|
+
import type { Json, Loc, OutNode, Structure } from 'kdlet';
|
|
20
|
+
import { FIELDS, GRID_ALIGNMENTS, GRID_FIELDS, GRID_PATTERNS, STYLE_TEXT_FIELDS, isStructured } from './fields.js';
|
|
21
|
+
import type { FieldSpec } from './fields.js';
|
|
22
|
+
import { SLOT_WORD, tagged } from './members.js';
|
|
23
|
+
import type { Component, LayerKind, Problem } from './model.js';
|
|
24
|
+
|
|
25
|
+
const HEX_COLOUR = /^#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
26
|
+
|
|
27
|
+
/** A value as the file wrote it, for a message: `(literal)8`, `#true`, `"abc"`. */
|
|
28
|
+
function show(json: Json | undefined): string {
|
|
29
|
+
const tag = tagged(json);
|
|
30
|
+
const raw = tag ? tag.value : json;
|
|
31
|
+
const body =
|
|
32
|
+
typeof raw === 'string'
|
|
33
|
+
? JSON.stringify(raw)
|
|
34
|
+
: raw === null || raw === undefined
|
|
35
|
+
? '#null'
|
|
36
|
+
: raw === true
|
|
37
|
+
? '#true'
|
|
38
|
+
: raw === false
|
|
39
|
+
? '#false'
|
|
40
|
+
: String(raw);
|
|
41
|
+
return `${tag ? `(${tag.tag})` : ''}${body}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isStructure = (json: Json | undefined): json is Structure =>
|
|
45
|
+
json !== null && typeof json === 'object' && !Array.isArray(json) && 'children' in json && 'props' in json;
|
|
46
|
+
|
|
47
|
+
const specOf = (name: string): FieldSpec | undefined => (Object.prototype.hasOwnProperty.call(FIELDS, name) ? FIELDS[name] : undefined);
|
|
48
|
+
|
|
49
|
+
class Sink {
|
|
50
|
+
readonly problems: Problem[] = [];
|
|
51
|
+
|
|
52
|
+
/** True for an artboard file: it has `slot`, no component properties, no `SLOT` layers and no `tokens=`. */
|
|
53
|
+
constructor(readonly artboard = false) {}
|
|
54
|
+
|
|
55
|
+
error(message: string, at: Loc): void {
|
|
56
|
+
this.problems.push({ severity: 'error', message, line: at.line, column: at.column });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A token's path must be one a token could have; the grammar is the core's. */
|
|
60
|
+
token(text: string, name: string, at: Loc): void {
|
|
61
|
+
try {
|
|
62
|
+
parseReferencePath(referenceTarget(text) ?? '');
|
|
63
|
+
} catch (error) {
|
|
64
|
+
this.error(`"${text}" is not a token reference: ${(error as Error).message} (in "${name}")`, at);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A plain whole number, for indices, spans and counts. */
|
|
69
|
+
count(json: Json | undefined, what: string, minimum: number, at: Loc): void {
|
|
70
|
+
if (typeof json === 'number' && Number.isInteger(json) && json >= minimum) return;
|
|
71
|
+
this.error(`${what} takes a whole number of at least ${minimum}, not ${show(json)}`, at);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Scalar fields: a property of the layer
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
function checkScalar(sink: Sink, name: string, spec: FieldSpec, json: Json, at: Loc): void {
|
|
80
|
+
const tag = tagged(json);
|
|
81
|
+
const raw = tag ? tag.value : json;
|
|
82
|
+
const tagName = tag ? tag.tag : null;
|
|
83
|
+
const fail = (expects: string): void => sink.error(`"${name}" takes ${expects}, not ${show(json)}`, at);
|
|
84
|
+
if (sink.artboard && tagName === 'property') {
|
|
85
|
+
sink.error(`"${name}" is wired to ${show(json)}, but an artboard has no component properties to wire to`, at);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const wired = (expects: string): void => {
|
|
89
|
+
if (!(spec.wires && typeof raw === 'string')) fail(expects);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
switch (spec.value) {
|
|
93
|
+
case 'boolean':
|
|
94
|
+
// On a wirable field `(literal)` says the value is fixed on purpose, in a `when` that takes the field off its property.
|
|
95
|
+
if ((tagName === null || (tagName === 'literal' && spec.wires)) && typeof raw === 'boolean') return;
|
|
96
|
+
// A `boolean` token binds its variable.
|
|
97
|
+
if (tagName === null && spec.token && typeof raw === 'string' && referenceTarget(raw) !== undefined) {
|
|
98
|
+
sink.token(raw, name, at);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (tagName === 'property') {
|
|
102
|
+
wired(`#true, #false or (property)"<${spec.wires} property>"`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
fail(spec.wires ? `#true, #false or (property)"<${spec.wires} property>"` : '#true or #false');
|
|
106
|
+
return;
|
|
107
|
+
|
|
108
|
+
case 'string':
|
|
109
|
+
if (tagName === 'literal' && spec.wires && typeof raw === 'string') return;
|
|
110
|
+
if (tagName === null && typeof raw === 'string') {
|
|
111
|
+
// A `string` token binds its variable; a field no token reaches takes text only.
|
|
112
|
+
if (referenceTarget(raw) !== undefined) {
|
|
113
|
+
if (spec.token) sink.token(raw, name, at);
|
|
114
|
+
else fail('text, and a token reference is not text');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (tagName === 'property') {
|
|
120
|
+
wired(`text or (property)"<${spec.wires} property>"`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
fail(`text or (property)"<${spec.wires} property>"`);
|
|
124
|
+
return;
|
|
125
|
+
|
|
126
|
+
case 'component':
|
|
127
|
+
if (tagName === null && typeof raw === 'string' && referenceTarget(raw) === undefined) return;
|
|
128
|
+
if (tagName === 'key' && typeof raw === 'string') return;
|
|
129
|
+
if (tagName === 'property') {
|
|
130
|
+
wired('a component name, (key)"…" or (property)"<INSTANCE_SWAP property>"');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
fail('a component name, (key)"…" or (property)"<INSTANCE_SWAP property>"');
|
|
134
|
+
return;
|
|
135
|
+
|
|
136
|
+
case 'token':
|
|
137
|
+
if (tagName === null && typeof raw === 'string' && referenceTarget(raw) !== undefined) {
|
|
138
|
+
sink.token(raw, name, at);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
fail('a token reference such as "{text.body}"');
|
|
142
|
+
return;
|
|
143
|
+
|
|
144
|
+
case 'group':
|
|
145
|
+
if (tagName === null && typeof raw === 'string' && referenceTarget(raw) !== undefined) {
|
|
146
|
+
sink.token(raw, name, at);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
fail('a token reference naming a group, such as "{button}"');
|
|
150
|
+
return;
|
|
151
|
+
|
|
152
|
+
case 'angle':
|
|
153
|
+
if ((tagName === null || tagName === 'literal') && typeof raw === 'number' && Number.isFinite(raw)) return;
|
|
154
|
+
fail('a number of degrees');
|
|
155
|
+
return;
|
|
156
|
+
|
|
157
|
+
case 'enum':
|
|
158
|
+
if (tagName === null && typeof raw === 'string' && spec.options?.includes(raw)) return;
|
|
159
|
+
fail(`one of ${spec.options?.join(' ')}`);
|
|
160
|
+
return;
|
|
161
|
+
|
|
162
|
+
case 'dimension':
|
|
163
|
+
case 'size': {
|
|
164
|
+
if (tagName === null && typeof raw === 'string' && referenceTarget(raw) !== undefined) {
|
|
165
|
+
sink.token(raw, name, at);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (tagName === 'literal' && typeof raw === 'number' && Number.isFinite(raw)) return;
|
|
169
|
+
// `#null` clears a limit; a width or a height has nothing to clear to, and the layer sizes itself by its sizing.
|
|
170
|
+
const clears = spec.value === 'size' && name !== 'width' && name !== 'height';
|
|
171
|
+
if (tagName === null && raw === null && clears) return;
|
|
172
|
+
if (tagName === null && raw === null && spec.value === 'size') {
|
|
173
|
+
sink.error(
|
|
174
|
+
`"${name}=#null": a ${name} cannot be cleared; to let the layer size itself, write layoutSizing${name === 'width' ? 'Horizontal' : 'Vertical'}=HUG (or FILL)`,
|
|
175
|
+
at,
|
|
176
|
+
);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const expects = `a token reference or a (literal) number${clears ? ', or #null' : ''}`;
|
|
180
|
+
if (tagName === null && typeof raw === 'number') {
|
|
181
|
+
sink.error(
|
|
182
|
+
`"${name}" takes ${expects}: a bare ${raw} would be a number nobody chose, write (literal)${raw} or a token reference`,
|
|
183
|
+
at,
|
|
184
|
+
);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
fail(expects);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
default:
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function checkPaint(sink: Sink, name: string, json: Json, at: Loc): void {
|
|
197
|
+
const tag = tagged(json);
|
|
198
|
+
const raw = tag ? tag.value : json;
|
|
199
|
+
if (!tag && typeof raw === 'string' && referenceTarget(raw) !== undefined) {
|
|
200
|
+
sink.token(raw, name, at);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (tag && tag.tag === 'literal' && typeof raw === 'string' && HEX_COLOUR.test(raw)) return;
|
|
204
|
+
if (!tag && typeof raw === 'string' && HEX_COLOUR.test(raw)) {
|
|
205
|
+
sink.error(`"${name}" has a bare colour ${show(raw)}: write (literal)${show(raw)} or a token reference`, at);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
sink.error(`"${name}" takes token references or (literal) colours such as (literal)"#rrggbb", not ${show(json)}`, at);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Structured fields: a child node of the layer
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
/** What a structured field may be built from: nothing it is not allowed to carry. */
|
|
216
|
+
function plainShape(
|
|
217
|
+
sink: Sink,
|
|
218
|
+
name: string,
|
|
219
|
+
s: Structure,
|
|
220
|
+
allow: { args?: boolean; props?: readonly string[]; children?: boolean },
|
|
221
|
+
): void {
|
|
222
|
+
const what = `"${name}"`;
|
|
223
|
+
if (!allow.args) for (const [index] of s.args.entries()) sink.error(`${what} takes no arguments`, s.argLocs[index]);
|
|
224
|
+
for (const key of Object.keys(s.props)) {
|
|
225
|
+
if (!allow.props?.includes(key))
|
|
226
|
+
sink.error(`${what} has no "${key}"${allow.props?.length ? `; it takes ${allow.props.join(', ')}` : ''}`, s.propLocs[key]);
|
|
227
|
+
}
|
|
228
|
+
if (!allow.children) for (const child of s.children) sink.error(`${what} takes no child nodes`, child.loc);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const emptyList = (json: Json): boolean => Array.isArray(json) && json.length === 0;
|
|
232
|
+
|
|
233
|
+
function checkTracks(sink: Sink, name: string, json: Json, at: Loc): void {
|
|
234
|
+
const what = `"${name}"`;
|
|
235
|
+
if (!isStructure(json)) {
|
|
236
|
+
sink.error(emptyList(json) ? `${what} needs at least one track` : `${what} takes its tracks as child nodes: FLEX, FIXED or HUG`, at);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
plainShape(sink, name, json, { children: true });
|
|
240
|
+
for (const track of json.children) {
|
|
241
|
+
const type = track.name;
|
|
242
|
+
const inner = `${type} in ${what}`;
|
|
243
|
+
for (const key of Object.keys(track.props)) sink.error(`${inner} has no "${key}"`, track.propLocs[key]);
|
|
244
|
+
for (const child of track.children) sink.error(`${inner} takes no child nodes`, child.loc);
|
|
245
|
+
if (type === 'HUG') {
|
|
246
|
+
for (const [index] of track.args.entries()) sink.error('HUG takes no arguments', track.argLocs[index]);
|
|
247
|
+
} else if (type === 'FLEX' || type === 'FIXED') {
|
|
248
|
+
const [number] = track.args;
|
|
249
|
+
if (track.args.length > 1) sink.error(`${type} takes one number`, track.argLocs[1]);
|
|
250
|
+
const where = track.argLocs[0] ?? track.loc;
|
|
251
|
+
if (type === 'FLEX' && number === undefined) continue;
|
|
252
|
+
if (type === 'FLEX' && typeof number === 'number' && number > 0) continue;
|
|
253
|
+
if (type === 'FIXED' && typeof number === 'number' && number >= 0) continue;
|
|
254
|
+
sink.error(
|
|
255
|
+
type === 'FLEX'
|
|
256
|
+
? `FLEX takes a number above 0, or none for 1, not ${show(number)}`
|
|
257
|
+
: `FIXED takes a number of at least 0, not ${number === undefined ? 'nothing' : show(number)}`,
|
|
258
|
+
where,
|
|
259
|
+
);
|
|
260
|
+
} else {
|
|
261
|
+
sink.error(`unknown track "${type}" in ${what}; a track is FLEX, FIXED or HUG`, track.loc);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (json.children.length === 0) sink.error(`${what} needs at least one track`, json.loc);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function checkPlacement(sink: Sink, name: string, json: Json, at: Loc): void {
|
|
268
|
+
const what = `"${name}"`;
|
|
269
|
+
if (!isStructure(json)) {
|
|
270
|
+
sink.error(emptyList(json) ? `${what} needs "row"` : `${what} takes no arguments`, at);
|
|
271
|
+
if (emptyList(json)) sink.error(`${what} needs "column"`, at);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
plainShape(sink, name, json, { props: ['row', 'column', 'rowSpan', 'columnSpan'] });
|
|
275
|
+
for (const [key, value] of Object.entries(json.props)) {
|
|
276
|
+
if (['row', 'column', 'rowSpan', 'columnSpan'].includes(key))
|
|
277
|
+
sink.count(value, `"${key}"`, key === 'rowSpan' || key === 'columnSpan' ? 1 : 0, json.propLocs[key]);
|
|
278
|
+
}
|
|
279
|
+
for (const key of ['row', 'column']) if (!(key in json.props)) sink.error(`${what} needs "${key}"`, json.loc);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function checkModes(sink: Sink, name: string, json: Json, at: Loc): void {
|
|
283
|
+
const what = `"${name}"`;
|
|
284
|
+
if (!isStructure(json)) {
|
|
285
|
+
sink.error(emptyList(json) ? `${what} needs at least one Collection=mode` : `${what} takes no arguments`, at);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
plainShape(sink, name, json, { props: Object.keys(json.props) });
|
|
289
|
+
for (const [collection, mode] of Object.entries(json.props)) {
|
|
290
|
+
if (typeof mode !== 'string' || mode === '')
|
|
291
|
+
sink.error(`"${collection}" in ${what} takes a mode name, not ${show(mode)}`, json.propLocs[collection]);
|
|
292
|
+
}
|
|
293
|
+
if (Object.keys(json.props).length === 0) sink.error(`${what} needs at least one Collection=mode`, json.loc);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** `set Name=value …`: one structure per line, each a name and a text, a boolean or a `(key)`; `set` adds, so the field holds a list of them. */
|
|
297
|
+
function checkSet(sink: Sink, name: string, json: Json, at: Loc): void {
|
|
298
|
+
const lines = Array.isArray(json) ? json : [json];
|
|
299
|
+
if (lines.length === 0) sink.error(`"${name}" needs at least one Name=value`, at);
|
|
300
|
+
for (const line of lines) {
|
|
301
|
+
if (!isStructure(line)) {
|
|
302
|
+
sink.error(`"${name}" takes properties, as in \`${name} Name="text" Enabled=#true\`, not ${show(line)}`, at);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
plainShape(sink, name, line, { props: Object.keys(line.props) });
|
|
306
|
+
if (Object.keys(line.props).length === 0) sink.error(`"${name}" needs at least one Name=value`, line.loc);
|
|
307
|
+
for (const [property, value] of Object.entries(line.props)) {
|
|
308
|
+
const tag = tagged(value);
|
|
309
|
+
const okay = tag
|
|
310
|
+
? tag.tag === 'key' && typeof tag.value === 'string' && tag.value !== ''
|
|
311
|
+
: typeof value === 'string' || typeof value === 'boolean';
|
|
312
|
+
if (!okay) {
|
|
313
|
+
sink.error(
|
|
314
|
+
`"${property}" in "${name}" takes text, #true, #false or (key)"…", not ${show(value)}`,
|
|
315
|
+
line.propLocs[property] ?? line.loc,
|
|
316
|
+
);
|
|
317
|
+
} else if (!tag && typeof value === 'string' && referenceTarget(value) !== undefined) {
|
|
318
|
+
// A `string` or `boolean` token binds its variable to the property.
|
|
319
|
+
sink.token(value, `${name} ${property}`, line.propLocs[property] ?? line.loc);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Wrap, grid child, position, appearance and strokes: the two structured fields they add.
|
|
326
|
+
|
|
327
|
+
/** `dashPattern "{stroke.dashed}"` (a strokeStyle token), `dashPattern (literal)4 (literal)2`, or a bare `dashPattern`: solid. */
|
|
328
|
+
function checkDashes(sink: Sink, name: string, json: Json, at: Loc): void {
|
|
329
|
+
let dashes: Json[] = [];
|
|
330
|
+
let locs: Loc[] = [];
|
|
331
|
+
if (isStructure(json)) {
|
|
332
|
+
plainShape(sink, name, json, { args: true });
|
|
333
|
+
[dashes, locs] = [json.args, json.argLocs];
|
|
334
|
+
} else if (Array.isArray(json)) {
|
|
335
|
+
[dashes, locs] = [json, json.map(() => at)];
|
|
336
|
+
}
|
|
337
|
+
const isToken = (dash: Json): boolean => !tagged(dash) && typeof dash === 'string' && referenceTarget(dash) !== undefined;
|
|
338
|
+
if (dashes.length > 1 && dashes.some(isToken)) {
|
|
339
|
+
sink.error(`"${name}" takes one token reference or (literal) lengths, not a token beside other values`, at);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
dashes.forEach((dash, index) => {
|
|
343
|
+
const tag = tagged(dash);
|
|
344
|
+
const raw = tag ? tag.value : dash;
|
|
345
|
+
if (isToken(dash)) {
|
|
346
|
+
sink.token(raw as string, name, locs[index]);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (tag?.tag === 'literal' && typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return;
|
|
350
|
+
if (!tag && typeof raw === 'number') {
|
|
351
|
+
sink.error(`"${name}" has a bare ${raw}: write (literal)${raw} or a token reference`, locs[index]);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
sink.error(`"${name}" takes a strokeStyle token reference or (literal) lengths of at least 0, not ${show(dash)}`, locs[index]);
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** `constraints horizontal=MIN vertical=STRETCH`: both axes, each one of the field's options. */
|
|
359
|
+
function checkConstraints(sink: Sink, name: string, spec: FieldSpec, json: Json, at: Loc): void {
|
|
360
|
+
const what = `"${name}"`;
|
|
361
|
+
if (!isStructure(json)) {
|
|
362
|
+
sink.error(`${what} takes horizontal= and vertical=, as in \`${name} horizontal=MIN vertical=MIN\``, at);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
plainShape(sink, name, json, { props: ['horizontal', 'vertical'] });
|
|
366
|
+
for (const axis of ['horizontal', 'vertical']) {
|
|
367
|
+
const value = json.props[axis];
|
|
368
|
+
if (!(axis in json.props)) sink.error(`${what} needs "${axis}"`, json.loc);
|
|
369
|
+
else if (typeof value !== 'string' || !spec.options?.includes(value)) {
|
|
370
|
+
sink.error(`"${axis}" in ${what} takes one of ${spec.options?.join(' ')}, not ${show(value)}`, json.propLocs[axis] ?? json.loc);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function checkStructured(sink: Sink, name: string, spec: FieldSpec, json: Json, at: Loc): void {
|
|
376
|
+
if (!isStructure(json) && !Array.isArray(json)) {
|
|
377
|
+
sink.error(`"${name}" is written as a child node, \`${name} …\`, not as a property`, at);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
switch (spec.value) {
|
|
381
|
+
case 'paints':
|
|
382
|
+
if (isStructure(json)) {
|
|
383
|
+
plainShape(sink, name, json, { args: true });
|
|
384
|
+
for (const [index, paint] of json.args.entries()) checkPaint(sink, name, paint, json.argLocs[index]);
|
|
385
|
+
} else {
|
|
386
|
+
for (const paint of json) checkPaint(sink, name, paint, at);
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
case 'tracks':
|
|
390
|
+
checkTracks(sink, name, json, at);
|
|
391
|
+
return;
|
|
392
|
+
case 'placement':
|
|
393
|
+
checkPlacement(sink, name, json, at);
|
|
394
|
+
return;
|
|
395
|
+
case 'set':
|
|
396
|
+
checkSet(sink, name, json, at);
|
|
397
|
+
return;
|
|
398
|
+
case 'effects':
|
|
399
|
+
if (isStructure(json)) {
|
|
400
|
+
plainShape(sink, name, json, { args: true });
|
|
401
|
+
for (const [index, effect] of json.args.entries()) checkEffect(sink, name, effect, json.argLocs[index]);
|
|
402
|
+
} else {
|
|
403
|
+
for (const effect of json) checkEffect(sink, name, effect, at);
|
|
404
|
+
}
|
|
405
|
+
return;
|
|
406
|
+
case 'grids':
|
|
407
|
+
checkGrids(sink, name, json, at);
|
|
408
|
+
return;
|
|
409
|
+
case 'dashes':
|
|
410
|
+
checkDashes(sink, name, json, at);
|
|
411
|
+
return;
|
|
412
|
+
case 'constraints':
|
|
413
|
+
checkConstraints(sink, name, spec, json, at);
|
|
414
|
+
return;
|
|
415
|
+
default:
|
|
416
|
+
checkModes(sink, name, json, at);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// Effects and layout grids (unit 4)
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
/** An effect: a shadow or a dimension token (a layer blur's radius), which the design system tells apart, or a `(literal)` radius. */
|
|
426
|
+
function checkEffect(sink: Sink, name: string, json: Json, at: Loc): void {
|
|
427
|
+
const tag = tagged(json);
|
|
428
|
+
const raw = tag ? tag.value : json;
|
|
429
|
+
if (!tag && typeof raw === 'string' && referenceTarget(raw) !== undefined) {
|
|
430
|
+
sink.token(raw, name, at);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (tag?.tag === 'literal' && typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return;
|
|
434
|
+
if (!tag && typeof raw === 'number') {
|
|
435
|
+
sink.error(`"${name}" has a bare ${raw}: write (literal)${raw} for a layer blur of that radius, or a token reference`, at);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
sink.error(
|
|
439
|
+
`"${name}" takes token references to shadows and dimensions (a layer blur's radius), or a (literal) radius, not ${show(json)}`,
|
|
440
|
+
at,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const GRID_KEYS = ['count', 'gutterSize', 'offset', 'sectionSize'];
|
|
445
|
+
const DIMENSION_SPEC: FieldSpec = { nodes: [], value: 'dimension' };
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* `layoutGrids { COLUMNS alignment=STRETCH count=12 gutterSize="{space.md}" … }`: one child node per grid, its fields
|
|
449
|
+
* as properties. A field the grid's alignment does not take is refused with its line, as Figma refuses to bind it.
|
|
450
|
+
*/
|
|
451
|
+
function checkGrids(sink: Sink, name: string, json: Json, at: Loc): void {
|
|
452
|
+
const what = `"${name}"`;
|
|
453
|
+
if (!isStructure(json)) {
|
|
454
|
+
sink.error(emptyList(json) ? `${what} needs at least one grid` : `${what} takes its grids as child nodes: COLUMNS, ROWS or GRID`, at);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
plainShape(sink, name, json, { children: true });
|
|
458
|
+
if (json.children.length === 0) sink.error(`${what} needs at least one grid`, json.loc);
|
|
459
|
+
for (const grid of json.children) {
|
|
460
|
+
const pattern = grid.name;
|
|
461
|
+
if (!(GRID_PATTERNS as readonly string[]).includes(pattern)) {
|
|
462
|
+
sink.error(`unknown grid "${pattern}" in ${what}; a grid is COLUMNS, ROWS or GRID`, grid.loc);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const inner = `${pattern} in ${what}`;
|
|
466
|
+
for (const [index] of grid.args.entries()) sink.error(`${inner} takes no arguments`, grid.argLocs[index]);
|
|
467
|
+
for (const child of grid.children) sink.error(`${inner} takes no child nodes`, child.loc);
|
|
468
|
+
const square = pattern === 'GRID';
|
|
469
|
+
const alignment = square ? 'GRID' : grid.props.alignment;
|
|
470
|
+
if (!square && alignment === undefined) sink.error(`${inner} needs "alignment": one of ${GRID_ALIGNMENTS.join(' ')}`, grid.loc);
|
|
471
|
+
else if (!square && !(GRID_ALIGNMENTS as readonly Json[]).includes(alignment)) {
|
|
472
|
+
sink.error(`"alignment" in ${inner} takes one of ${GRID_ALIGNMENTS.join(' ')}, not ${show(alignment)}`, grid.propLocs.alignment);
|
|
473
|
+
}
|
|
474
|
+
const takes = typeof alignment === 'string' ? GRID_FIELDS[alignment] : undefined;
|
|
475
|
+
for (const [key, value] of Object.entries(grid.props)) {
|
|
476
|
+
const loc = grid.propLocs[key] ?? grid.loc;
|
|
477
|
+
if (key === 'alignment' && !square) continue;
|
|
478
|
+
if (key === 'color') {
|
|
479
|
+
checkPaint(sink, `color" in "${pattern}`, value, loc);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (!GRID_KEYS.includes(key)) {
|
|
483
|
+
sink.error(
|
|
484
|
+
`${inner} has no "${key}"; it takes ${[...(square ? [] : ['alignment']), ...(square ? ['sectionSize'] : GRID_KEYS), 'color'].join(', ')}`,
|
|
485
|
+
loc,
|
|
486
|
+
);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (takes && !takes.includes(key)) {
|
|
490
|
+
sink.error(
|
|
491
|
+
`"${key}" does not apply to ${square ? 'a square GRID' : `a ${alignment} grid`} in ${what}: Figma takes ${takes.join(', ')} there, and refuses to bind "${key}"`,
|
|
492
|
+
loc,
|
|
493
|
+
);
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (key !== 'count') checkScalar(sink, key, DIMENSION_SPEC, value, loc);
|
|
497
|
+
else {
|
|
498
|
+
const tag = tagged(value);
|
|
499
|
+
const raw = tag ? tag.value : value;
|
|
500
|
+
if (!tag && typeof raw === 'string' && referenceTarget(raw) !== undefined) sink.token(raw, key, loc);
|
|
501
|
+
else if ((!tag || tag.tag === 'literal') && typeof raw === 'number' && Number.isInteger(raw) && raw >= 1) continue;
|
|
502
|
+
else sink.error(`"count" in ${inner} takes a token reference or a whole number of at least 1, not ${show(value)}`, loc);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// What Figma needs to draw the grid: the offset alone defaults, to 0.
|
|
506
|
+
for (const key of takes ?? []) if (key !== 'offset' && !(key in grid.props)) sink.error(`${inner} needs "${key}"`, grid.loc);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
// Layers
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
/** `slot Content { … }`: the layers that fill a slot of the instance it stands in. It has a name and layers inside, and no fields of its own. */
|
|
515
|
+
function checkSlotWord(sink: Sink, node: OutNode, parent: LayerKind): void {
|
|
516
|
+
if (!sink.artboard) {
|
|
517
|
+
sink.error(
|
|
518
|
+
`"${SLOT_WORD}" fills an instance's slot in an artboard file; a component file makes a slot with a SLOT layer and a SLOT property`,
|
|
519
|
+
node.loc,
|
|
520
|
+
);
|
|
521
|
+
} else if (parent !== 'INSTANCE') {
|
|
522
|
+
sink.error(
|
|
523
|
+
`"${SLOT_WORD}" fills a slot of the instance it stands in, so it stands directly inside an INSTANCE, not a ${parent}`,
|
|
524
|
+
node.loc,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
const first = node.args[0];
|
|
528
|
+
if (typeof first !== 'string' || first === '')
|
|
529
|
+
sink.error(`${SLOT_WORD} needs the name of the slot it fills as its first argument`, node.loc);
|
|
530
|
+
if (node.args.length > 1) sink.error(`${SLOT_WORD} takes one name`, node.argLocs[1]);
|
|
531
|
+
for (const field of Object.keys(node.fields))
|
|
532
|
+
sink.error(`${SLOT_WORD} "${String(first ?? '')}" has no field "${field}"; it holds layers`, node.fieldLocs[field] ?? node.loc);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function checkNode(sink: Sink, node: OutNode): void {
|
|
536
|
+
if (node.kind === 'call') {
|
|
537
|
+
sink.error(`"+${node.name}" calls a definition this file does not define`, node.loc);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const kind = node.name as LayerKind;
|
|
541
|
+
const first = node.args[0];
|
|
542
|
+
const named = typeof first === 'string' && first !== '';
|
|
543
|
+
if (!named) sink.error(`${kind} needs a name as its first argument`, node.loc);
|
|
544
|
+
if (node.args.length > 1) sink.error(`${kind} takes one name`, node.argLocs[1]);
|
|
545
|
+
const what = `${kind} "${named ? first : ''}"`;
|
|
546
|
+
if (sink.artboard && kind === 'SLOT') {
|
|
547
|
+
sink.error(
|
|
548
|
+
`${what}: a SLOT layer makes the slot of a component; in an artboard, fill an instance's slot with \`${SLOT_WORD} <Name> { … }\` inside the INSTANCE`,
|
|
549
|
+
node.loc,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
for (const [name, json] of Object.entries(node.fields)) {
|
|
554
|
+
const at = node.fieldLocs[name] ?? node.loc;
|
|
555
|
+
const spec = specOf(name);
|
|
556
|
+
if (!spec) sink.error(`unknown field "${name}" on ${what}`, at);
|
|
557
|
+
else if (name === 'tokens' && kind === 'INSTANCE')
|
|
558
|
+
sink.error(`tokens does not apply to an INSTANCE layer, in ${what}: an instance's overrides are not in this design`, at);
|
|
559
|
+
else if (name === 'tokens' && sink.artboard)
|
|
560
|
+
sink.error(`tokens does not apply in an artboard file, in ${what}: bind each field with a token reference`, at);
|
|
561
|
+
else if (name === 'isExposedInstance' && sink.artboard)
|
|
562
|
+
sink.error(
|
|
563
|
+
`isExposedInstance does not apply in an artboard file, in ${what}: an exposed instance is one inside a component, whose instances list its properties`,
|
|
564
|
+
at,
|
|
565
|
+
);
|
|
566
|
+
else if (!spec.nodes.includes(kind)) {
|
|
567
|
+
sink.error(
|
|
568
|
+
`"${name}" does not apply to a ${kind} layer, in ${what}${kind === 'LINE' && /[hH]eight$/.test(name) ? ': a line has a length, width, and no height' : ''}`,
|
|
569
|
+
at,
|
|
570
|
+
);
|
|
571
|
+
} else if (isStructured(spec)) checkStructured(sink, name, spec, json, at);
|
|
572
|
+
else checkScalar(sink, name, spec, json, at);
|
|
573
|
+
}
|
|
574
|
+
if (kind === 'TEXT') checkText(sink, node, what);
|
|
575
|
+
if ('effects' in node.fields && 'effectStyle' in node.fields) {
|
|
576
|
+
// Unit 4: a style sets the layer's effects, so the two fields would be two writers of one list.
|
|
577
|
+
sink.error(
|
|
578
|
+
`${what} has both "effects" and "effectStyle": the effect style sets the effects that "effects" sets, so the two would disagree; keep one`,
|
|
579
|
+
node.fieldLocs.effects ?? node.loc,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
if (kind === 'INSTANCE' && !('component' in node.fields)) {
|
|
583
|
+
// An instance is an instance of something.
|
|
584
|
+
sink.error(
|
|
585
|
+
`${what} needs component=<a component's name>, component=(key)"…" or component=(property)"<INSTANCE_SWAP property>"`,
|
|
586
|
+
node.loc,
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const slots = new Set<string>();
|
|
591
|
+
for (const child of node.children) {
|
|
592
|
+
if (child.kind === 'element' && child.name === SLOT_WORD) {
|
|
593
|
+
checkSlotWord(sink, child, kind);
|
|
594
|
+
const slotName = String(child.args[0] ?? '');
|
|
595
|
+
if (slots.has(slotName)) sink.error(`${what} fills the slot "${slotName}" twice; write one ${SLOT_WORD} for it`, child.loc);
|
|
596
|
+
slots.add(slotName);
|
|
597
|
+
// What stands inside a slot is a layer, and may not be another `slot`.
|
|
598
|
+
for (const inner of child.children) {
|
|
599
|
+
if (inner.kind === 'element' && inner.name === SLOT_WORD)
|
|
600
|
+
sink.error(`${SLOT_WORD} stands inside an INSTANCE, not inside another ${SLOT_WORD}`, inner.loc);
|
|
601
|
+
else checkNode(sink, inner);
|
|
602
|
+
}
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
if (child.kind === 'element' && (kind === 'TEXT' || kind === 'INSTANCE' || kind === 'LINE')) {
|
|
606
|
+
sink.error(
|
|
607
|
+
`${what} cannot hold ${child.name} "${String(child.args[0] ?? '')}": a ${kind} has no child layers${sink.artboard && kind === 'INSTANCE' ? `; put it in a slot, ${SLOT_WORD} <Name> { … }` : ''}`,
|
|
608
|
+
child.loc,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
checkNode(sink, child);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ---------------------------------------------------------------------------
|
|
616
|
+
// Text fields
|
|
617
|
+
// ---------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* A text field beside `textStyle` is a second source for one value, the style's and the field's, and is refused naming
|
|
621
|
+
* both. `maxLines` is a whole number of at least 1: Figma refuses 0 (measured §14).
|
|
622
|
+
*/
|
|
623
|
+
function checkText(sink: Sink, node: OutNode, what: string): void {
|
|
624
|
+
if ('textStyle' in node.fields) {
|
|
625
|
+
for (const field of STYLE_TEXT_FIELDS) {
|
|
626
|
+
if (field in node.fields) {
|
|
627
|
+
sink.error(
|
|
628
|
+
`"${field}" and "textStyle" on ${what}: a text style sets the ${field} too, and two sources for one value would disagree; write one of them`,
|
|
629
|
+
node.fieldLocs[field] ?? node.loc,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
const maxLines = tagged(node.fields.maxLines);
|
|
635
|
+
if (maxLines?.tag === 'literal' && !(typeof maxLines.value === 'number' && Number.isInteger(maxLines.value) && maxLines.value >= 1)) {
|
|
636
|
+
sink.error(
|
|
637
|
+
`"maxLines" on ${what} takes a whole number of at least 1, not ${show(node.fields.maxLines)}`,
|
|
638
|
+
node.fieldLocs.maxLines ?? node.loc,
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* What is wrong in one member's tree, by Toghen's rules: the root, and every layer's
|
|
645
|
+
* fields. `at` is where the component is named, for a file with no root layer.
|
|
646
|
+
*/
|
|
647
|
+
export function checkRoots(roots: OutNode[], component: string, at: Loc): Problem[] {
|
|
648
|
+
const sink = new Sink();
|
|
649
|
+
if (roots.length === 0) sink.error(`component "${component}" needs one root FRAME`, at);
|
|
650
|
+
roots.forEach((root, index) => {
|
|
651
|
+
if (index > 0) sink.error('a component has exactly one root FRAME; this is a second', root.loc);
|
|
652
|
+
else if (root.kind === 'element' && root.name !== 'FRAME')
|
|
653
|
+
sink.error(`the component's root layer must be a FRAME, not a ${root.name}`, root.loc);
|
|
654
|
+
if (root.kind === 'element' && root.name === SLOT_WORD) return;
|
|
655
|
+
checkNode(sink, root);
|
|
656
|
+
});
|
|
657
|
+
return sink.problems;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* A pin the file writes on the root for a collection an axis already pins.
|
|
662
|
+
*
|
|
663
|
+
* `VARIANT State (modifier)"state"` pins the collection `state` on each member's root, so a
|
|
664
|
+
* `modes state=…` the file writes there (or a `when` writes there) would be a second writer of
|
|
665
|
+
* one pin, and the two would disagree in some member. It is an error, not a quiet override.
|
|
666
|
+
*/
|
|
667
|
+
export function checkPins(roots: OutNode[], component: Component): Problem[] {
|
|
668
|
+
const sink = new Sink();
|
|
669
|
+
const pinned = new Map<string, string>();
|
|
670
|
+
for (const property of component.properties) {
|
|
671
|
+
if (property.type === 'VARIANT' && property.modifier !== undefined) pinned.set(property.modifier, property.name);
|
|
672
|
+
}
|
|
673
|
+
const root = roots[0];
|
|
674
|
+
const modes = root?.fields.modes;
|
|
675
|
+
if (pinned.size === 0 || !isStructure(modes)) return sink.problems;
|
|
676
|
+
const at = root.fieldLocs?.modes ?? root.loc;
|
|
677
|
+
for (const collection of Object.keys(modes.props)) {
|
|
678
|
+
const axis = pinned.get(collection);
|
|
679
|
+
if (axis !== undefined) {
|
|
680
|
+
sink.error(
|
|
681
|
+
`modes ${collection}=… on the root layer: the VARIANT axis "${axis}" already pins "${collection}" to its value in every member; ` +
|
|
682
|
+
`two writers of one pin would disagree, so the file does not write it`,
|
|
683
|
+
at,
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return sink.problems;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* What is wrong in an artboard file's tree, by Toghen's rules: the root, and every layer's fields.
|
|
692
|
+
* `at` is where the artboard is named, for a file with no root layer.
|
|
693
|
+
*/
|
|
694
|
+
export function checkArtboardRoots(roots: OutNode[], artboard: string, at: Loc): Problem[] {
|
|
695
|
+
const sink = new Sink(true);
|
|
696
|
+
if (roots.length === 0) sink.error(`artboard "${artboard}" needs one root FRAME`, at);
|
|
697
|
+
roots.forEach((root, index) => {
|
|
698
|
+
if (index > 0) sink.error('an artboard has exactly one root FRAME; this is a second', root.loc);
|
|
699
|
+
else if (root.kind === 'element' && root.name !== 'FRAME')
|
|
700
|
+
sink.error(`the artboard's root layer must be a FRAME, not a ${root.name}`, root.loc);
|
|
701
|
+
if (root.kind === 'element' && root.name === SLOT_WORD) return;
|
|
702
|
+
checkNode(sink, root);
|
|
703
|
+
});
|
|
704
|
+
return sink.problems;
|
|
705
|
+
}
|