@toghen/figma 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -2
  3. package/cli/bin.mjs +19 -0
  4. package/cli/bridge.ts +243 -0
  5. package/cli/check.ts +62 -0
  6. package/cli/figcraft.ts +764 -0
  7. package/cli/figcraftApply.ts +1067 -0
  8. package/cli/figcraftFile.ts +476 -0
  9. package/cli/figcraftLibrary.ts +147 -0
  10. package/cli/figcraftRecord.ts +121 -0
  11. package/cli/figcraftScene.ts +661 -0
  12. package/cli/main.ts +218 -0
  13. package/cli/members.ts +65 -0
  14. package/cli/run.ts +756 -0
  15. package/component-files.md +665 -0
  16. package/dist/code.js +24161 -0
  17. package/dist/manifest.json +23 -0
  18. package/dist/ui.html +6918 -0
  19. package/guide.md +985 -0
  20. package/main/code.ts +50 -0
  21. package/main/components/apply.ts +704 -0
  22. package/main/components/endOfRun.ts +168 -0
  23. package/main/components/errors.ts +17 -0
  24. package/main/components/fieldWriter.ts +497 -0
  25. package/main/components/file.ts +263 -0
  26. package/main/components/receipt.ts +228 -0
  27. package/main/components/resolve.ts +1193 -0
  28. package/main/components/run.ts +273 -0
  29. package/main/components/snapshot.ts +341 -0
  30. package/main/components/stamp.ts +312 -0
  31. package/main/components/update.ts +465 -0
  32. package/main/dispatcher.ts +33 -0
  33. package/main/engines/batchRunner.ts +200 -0
  34. package/main/engines/ioAdapter.ts +132 -0
  35. package/main/engines/operationExecutor.ts +512 -0
  36. package/main/engines/propertyMapper.ts +221 -0
  37. package/main/engines/queryEngine.ts +45 -0
  38. package/main/engines/styleSyncEngine.ts +444 -0
  39. package/main/engines/variableSyncEngine.ts +561 -0
  40. package/main/handlers/auditSelection.ts +60 -0
  41. package/main/handlers/boundLayers.ts +51 -0
  42. package/main/handlers/cancelOperation.ts +18 -0
  43. package/main/handlers/executeApplyBindings.ts +69 -0
  44. package/main/handlers/executeScaffold.ts +59 -0
  45. package/main/handlers/executeStyleSync.ts +121 -0
  46. package/main/handlers/executeVariableSync.ts +82 -0
  47. package/main/handlers/exportDocument.ts +114 -0
  48. package/main/handlers/getSelection.ts +43 -0
  49. package/main/handlers/index.ts +30 -0
  50. package/main/handlers/inspectCorrespondence.ts +172 -0
  51. package/main/handlers/loadConventions.ts +86 -0
  52. package/main/handlers/loadDocument.ts +253 -0
  53. package/main/handlers/lock.ts +270 -0
  54. package/main/handlers/planApply.ts +157 -0
  55. package/main/handlers/planApplyBindings.ts +79 -0
  56. package/main/handlers/planScaffold.ts +84 -0
  57. package/main/handlers/planStyleSync.ts +49 -0
  58. package/main/handlers/planVariableSync.ts +49 -0
  59. package/main/handlers/readSelectionBindings.ts +160 -0
  60. package/main/handlers/resizeUi.ts +105 -0
  61. package/main/handlers/runComponent.ts +65 -0
  62. package/main/handlers/templates.ts +462 -0
  63. package/main/handlers/tokenGraph.ts +122 -0
  64. package/main/runtimeState.ts +66 -0
  65. package/main/utils/abortController.ts +11 -0
  66. package/main/utils/boundVariables.ts +84 -0
  67. package/main/utils/buildPayloads.ts +85 -0
  68. package/main/utils/correspondence.ts +658 -0
  69. package/main/utils/designSource.ts +122 -0
  70. package/main/utils/figmaCollectionIndex.ts +120 -0
  71. package/main/utils/figmaStyleAdapter.ts +268 -0
  72. package/main/utils/figmaTokenAdapter.ts +255 -0
  73. package/main/utils/flattenDesignSystem.ts +38 -0
  74. package/main/utils/folds.ts +203 -0
  75. package/main/utils/fontStack.ts +57 -0
  76. package/main/utils/lists.ts +4 -0
  77. package/main/utils/lockStore.ts +72 -0
  78. package/main/utils/moves.ts +67 -0
  79. package/main/utils/pageRoots.ts +58 -0
  80. package/main/utils/planSymbols.ts +96 -0
  81. package/main/utils/purpose.ts +351 -0
  82. package/main/utils/recentStore.ts +166 -0
  83. package/main/utils/resolverModes.ts +271 -0
  84. package/main/utils/rootRecord.ts +137 -0
  85. package/main/utils/shardedData.ts +142 -0
  86. package/main/utils/standing.ts +106 -0
  87. package/main/utils/styleTarget.ts +51 -0
  88. package/main/utils/sum.ts +612 -0
  89. package/main/utils/symbolicIdRegistry.ts +26 -0
  90. package/main/utils/syncState.ts +133 -0
  91. package/main/utils/templateStore.ts +73 -0
  92. package/main/utils/tokenPath.ts +28 -0
  93. package/main/utils/tokenStamps.ts +91 -0
  94. package/manifest.json +15 -0
  95. package/package.json +64 -4
  96. package/reference.md +1609 -0
  97. package/shared/components/artboard.ts +126 -0
  98. package/shared/components/axes.ts +420 -0
  99. package/shared/components/bindings.ts +64 -0
  100. package/shared/components/blueprint.ts +248 -0
  101. package/shared/components/check.ts +705 -0
  102. package/shared/components/diff.ts +668 -0
  103. package/shared/components/fields.ts +404 -0
  104. package/shared/components/groups.ts +306 -0
  105. package/shared/components/index.ts +140 -0
  106. package/shared/components/members.ts +280 -0
  107. package/shared/components/model.ts +273 -0
  108. package/shared/components/order.ts +53 -0
  109. package/shared/components/read.ts +480 -0
  110. package/shared/components/stack.ts +124 -0
  111. package/shared/components/steps.ts +186 -0
  112. package/shared/components/validate.ts +565 -0
  113. package/shared/conventions.ts +28 -0
  114. package/shared/inputs.ts +40 -0
  115. package/shared/operations.ts +233 -0
  116. package/shared/pendingNames.ts +39 -0
  117. package/shared/protocol.ts +588 -0
  118. package/shared/tokenTypeMap.ts +21 -0
@@ -0,0 +1,480 @@
1
+ /**
2
+ * KDL text to a component's header and a document.
3
+ *
4
+ * A component file is a kdlet file. kdlet reads it, and hands back the five words
5
+ * that are Toghen's (`component`, `description`, `properties`, `exclude`, `include`) as
6
+ * parser nodes, unread; this file reads those into the model's `Component`, and
7
+ * leaves the rest, the layers and everything that varies, to kdlet's evaluation
8
+ * (`members.ts`).
9
+ *
10
+ * The reader is tolerant on purpose: a value it cannot accept is reported and
11
+ * left out, and reading carries on, so one run names every problem in the file
12
+ * rather than the first. What it refuses here is the shape of the five words; the
13
+ * layers are `check.ts`'s, and what needs more than one node is `validate.ts`'s.
14
+ */
15
+
16
+ import { getLocation } from '@bgotink/kdl';
17
+ import type { Entry, Node } from '@bgotink/kdl';
18
+ import { read as readDocument } from 'kdlet';
19
+ import type { Document } from 'kdlet';
20
+ import { parseReferencePath, referenceTarget } from '@toghen/lib';
21
+ import { INPUT_SEGMENT } from './axes.js';
22
+ import { PROPERTY_TYPES } from './fields.js';
23
+ import type { Component, ComponentRef, Exclusion, Loc, PreferredValue, Problem, Property, SlotSettings } from './model.js';
24
+
25
+ /** The top-level words that are Toghen's, and not kdlet's or Figma's. */
26
+ export const HOST_WORDS = ['component', 'description', 'properties', 'exclude', 'include'] as const;
27
+
28
+ type Sink = Problem[];
29
+ type Raw = string | number | boolean | null;
30
+
31
+ /** Where a node or entry starts, one-based, as an editor shows it. */
32
+ export function locOf(element: Node | Entry): Loc | undefined {
33
+ const stored = getLocation(element);
34
+ return stored ? { line: stored.start.line, column: stored.start.column } : undefined;
35
+ }
36
+
37
+ export function report(sink: Sink, message: string, at?: Node | Entry, severity: Problem['severity'] = 'error'): void {
38
+ const loc = at ? locOf(at) : undefined;
39
+ sink.push({ severity, message, ...(loc ?? {}) });
40
+ }
41
+
42
+ /** A value as the file wrote it, for a message: `(literal)8`, `#true`, `"abc"`. */
43
+ function show(tag: string | null, raw: Raw): string {
44
+ const body =
45
+ typeof raw === 'string'
46
+ ? JSON.stringify(raw)
47
+ : raw === null
48
+ ? '#null'
49
+ : raw === true
50
+ ? '#true'
51
+ : raw === false
52
+ ? '#false'
53
+ : String(raw);
54
+ return `${tag ? `(${tag})` : ''}${body}`;
55
+ }
56
+
57
+ /** The arguments and the properties of a node, with a property written twice reported and dropped. */
58
+ export function split(sink: Sink, node: Node, what: string): { args: Entry[]; props: [string, Entry][] } {
59
+ const args: Entry[] = [];
60
+ const props: [string, Entry][] = [];
61
+ const seen = new Set<string>();
62
+ for (const entry of node.entries) {
63
+ const key = entry.getName();
64
+ if (key === null) {
65
+ args.push(entry);
66
+ } else if (seen.has(key)) {
67
+ report(sink, `"${key}" is written twice on ${what}; KDL would keep only the last`, entry);
68
+ } else {
69
+ seen.add(key);
70
+ props.push([key, entry]);
71
+ }
72
+ }
73
+ return { args, props };
74
+ }
75
+
76
+ function noArguments(sink: Sink, args: Entry[], what: string): void {
77
+ for (const arg of args) report(sink, `${what} takes no arguments`, arg);
78
+ }
79
+
80
+ function noChildren(sink: Sink, node: Node, what: string): void {
81
+ for (const child of node.children?.nodes ?? []) report(sink, `${what} takes no child nodes`, child);
82
+ }
83
+
84
+ function onlyKeys(sink: Sink, props: [string, Entry][], allowed: string[], what: string): void {
85
+ for (const [key, entry] of props) {
86
+ if (!allowed.includes(key)) {
87
+ report(sink, `${what} has no "${key}"${allowed.length ? `; it takes ${allowed.join(', ')}` : ''}`, entry);
88
+ }
89
+ }
90
+ }
91
+
92
+ /** A plain non-negative whole number, for indices, spans and counts. */
93
+ function readCount(sink: Sink, entry: Entry, what: string, minimum: number): number | undefined {
94
+ const raw = entry.getValue();
95
+ if (entry.getTag() === null && typeof raw === 'number' && Number.isInteger(raw) && raw >= minimum) return raw;
96
+ report(sink, `${what} takes a whole number of at least ${minimum}, not ${show(entry.getTag(), raw)}`, entry);
97
+ return undefined;
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // exclude
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /** `exclude Axis=value …`: one value for each axis it names. */
105
+ function readCombination(sink: Sink, node: Node, what: string): Record<string, string> {
106
+ const { args, props } = split(sink, node, what);
107
+ noArguments(sink, args, what);
108
+ const combination: Record<string, string> = {};
109
+ for (const [axis, entry] of props) {
110
+ const raw = entry.getValue();
111
+ if (entry.getTag() !== null || typeof raw !== 'string' || raw === '') {
112
+ report(sink, `${what} takes ${axis}="<value>", not ${show(entry.getTag(), raw)}; quote a value that is not a word`, entry);
113
+ continue;
114
+ }
115
+ combination[axis] = raw;
116
+ }
117
+ if (props.length === 0) report(sink, `${what} needs at least one Axis=value`, node);
118
+ return combination;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Properties
123
+ // ---------------------------------------------------------------------------
124
+
125
+ function propertyName(sink: Sink, arg: Entry | undefined, type: string, node: Node): string | undefined {
126
+ const name = arg?.getValue();
127
+ if (!arg || arg.getTag() !== null || typeof name !== 'string' || name === '') {
128
+ report(sink, `${type} needs a name as its first argument`, node);
129
+ return undefined;
130
+ }
131
+ if (name.includes('#')) {
132
+ report(sink, `${type} "${name}": a property name cannot contain "#", which Figma uses to tell properties apart`, arg);
133
+ }
134
+ return name;
135
+ }
136
+
137
+ function readPreferred(sink: Sink, node: Node, what: string): PreferredValue[] | undefined {
138
+ const preferred: PreferredValue[] = [];
139
+ for (const child of node.children?.nodes ?? []) {
140
+ if (child.getName() !== 'preferred') {
141
+ report(
142
+ sink,
143
+ `unknown node "${child.getName()}" in ${what}; only "preferred COMPONENT key=…" or "preferred COMPONENT name=…" belongs here`,
144
+ child,
145
+ );
146
+ continue;
147
+ }
148
+ const { args, props } = split(sink, child, 'preferred');
149
+ noChildren(sink, child, 'preferred');
150
+ onlyKeys(sink, props, ['key', 'name'], 'preferred');
151
+ const type = args[0]?.getValue();
152
+ const key = props.find(([k]) => k === 'key')?.[1];
153
+ const name = props.find(([k]) => k === 'name')?.[1];
154
+ const value = (key ?? name)?.getValue();
155
+ if (args.length !== 1 || (type !== 'COMPONENT' && type !== 'COMPONENT_SET')) {
156
+ report(sink, `preferred takes COMPONENT or COMPONENT_SET as its argument`, args[0] ?? child);
157
+ } else if (key && name) {
158
+ report(sink, `preferred ${type} takes key= or name=, not both`, child);
159
+ } else if (typeof value !== 'string' || value === '') {
160
+ report(sink, `preferred ${type} needs key="<published key>" or name="<component name>"`, child);
161
+ } else if (key) {
162
+ preferred.push({ type, key: value });
163
+ } else {
164
+ const loc = locOf(child);
165
+ preferred.push({ type, name: value, ...(loc ? { loc } : {}) });
166
+ }
167
+ }
168
+ return preferred.length ? preferred : undefined;
169
+ }
170
+
171
+ function readProperty(sink: Sink, node: Node): Property | undefined {
172
+ const type = node.getName();
173
+ if (!(PROPERTY_TYPES as readonly string[]).includes(type)) {
174
+ report(sink, `unknown property type "${type}"; a property is VARIANT, BOOLEAN, TEXT, INSTANCE_SWAP or SLOT`, node);
175
+ return undefined;
176
+ }
177
+ const { args, props } = split(sink, node, type);
178
+ const name = propertyName(sink, args[0], type, node);
179
+ const loc = locOf(node);
180
+ const at = loc ? { loc } : {};
181
+ const what = `${type} "${name ?? ''}"`;
182
+ const prop = (key: string) => props.find(([k]) => k === key)?.[1];
183
+
184
+ if (type === 'VARIANT') {
185
+ onlyKeys(sink, props, ['from'], what);
186
+ noChildren(sink, node, what);
187
+ if (name === undefined) return undefined;
188
+ if (/[,=.]/.test(name)) {
189
+ report(
190
+ sink,
191
+ `${what}: an axis name cannot contain ",", "=" or ".": member names use the first two, and a guard reads the axis as (input)<name>, which takes no dot`,
192
+ args[0],
193
+ );
194
+ }
195
+ // `from="{group}"` alone: the values are the names of the group's children, which the design system gives (`axes.ts`).
196
+ const from = prop('from');
197
+ if (from) {
198
+ const group = from.getValue();
199
+ if (args.length !== 1) {
200
+ report(sink, `${what} takes from="{group}" alone, or a list of values; not both, and not (modifier)`, args[1] ?? from);
201
+ } else if (from.getTag() !== null || typeof group !== 'string' || referenceTarget(group) === undefined) {
202
+ report(sink, `${what}: from takes a token reference in quotes, such as from="{hue}", not ${show(from.getTag(), group)}`, from);
203
+ } else if (group.includes(INPUT_SEGMENT)) {
204
+ report(
205
+ sink,
206
+ `${what}: from="${group}" cannot hold an ${INPUT_SEGMENT} segment: the values of an axis cannot come from an axis`,
207
+ from,
208
+ );
209
+ } else {
210
+ try {
211
+ const target = referenceTarget(group)!;
212
+ parseReferencePath(target);
213
+ return { type, name, values: [], from: target, ...at };
214
+ } catch (error) {
215
+ report(sink, `${what}: from=${show(null, group)} is not a token reference: ${(error as Error).message}`, from);
216
+ }
217
+ }
218
+ return undefined;
219
+ }
220
+ // `(modifier)"state"` alone: the values are the modifier's contexts, which the design system gives (`axes.ts`).
221
+ const fromModifier = args.slice(1).find((arg) => arg.getTag() === 'modifier');
222
+ if (fromModifier) {
223
+ const modifier = fromModifier.getValue();
224
+ if (args.length !== 2) {
225
+ report(sink, `${what} takes (modifier)"<name>" alone, or a list of values; not both, and not several modifiers`, fromModifier);
226
+ } else if (typeof modifier !== 'string' || modifier === '') {
227
+ report(sink, `${what}: (modifier) takes the name of a modifier of the design system, in quotes`, fromModifier);
228
+ } else {
229
+ return { type, name, values: [], modifier, ...at };
230
+ }
231
+ return undefined;
232
+ }
233
+ const values: string[] = [];
234
+ for (const arg of args.slice(1)) {
235
+ const value = arg.getValue();
236
+ if (arg.getTag() !== null || typeof value !== 'string') {
237
+ report(sink, `${what} has value ${show(arg.getTag(), value)}, which is not a string; quote it, as in "${String(value)}"`, arg);
238
+ } else if (value === '') {
239
+ report(sink, `${what} has an empty value`, arg);
240
+ } else if (/[,=]/.test(value)) {
241
+ report(sink, `${what} has value "${value}": a variant value cannot contain "," or "=", which member names use`, arg);
242
+ } else if (values.includes(value)) {
243
+ report(sink, `${what} lists "${value}" twice`, arg);
244
+ } else {
245
+ values.push(value);
246
+ }
247
+ }
248
+ if (values.length === 0 && args.length <= 1) report(sink, `${what} needs at least one value`, node);
249
+ return { type, name, values, ...at };
250
+ }
251
+
252
+ if (args.length > 1) report(sink, `${what} takes one name`, args[1]);
253
+
254
+ if (type === 'BOOLEAN' || type === 'TEXT') {
255
+ onlyKeys(sink, props, ['default'], what);
256
+ noChildren(sink, node, what);
257
+ const entry = prop('default');
258
+ const value = entry?.getValue();
259
+ // A token reference binds the default to its variable: a `boolean` token's, or a `string` token's.
260
+ let token: { token: string } | undefined;
261
+ if (entry && entry.getTag() === null && typeof value === 'string' && referenceTarget(value) !== undefined) {
262
+ try {
263
+ parseReferencePath(referenceTarget(value)!);
264
+ token = { token: referenceTarget(value)! };
265
+ } catch (error) {
266
+ report(sink, `${what}: default=${show(null, value)} is not a token reference: ${(error as Error).message}`, entry);
267
+ return undefined;
268
+ }
269
+ }
270
+ const ok = token !== undefined || (entry && entry.getTag() === null && typeof value === (type === 'BOOLEAN' ? 'boolean' : 'string'));
271
+ if (!ok) {
272
+ report(sink, `${what} needs default=${type === 'BOOLEAN' ? '#true or #false' : '"<text>"'}, or a token reference`, entry ?? node);
273
+ }
274
+ if (name === undefined || !ok) return undefined;
275
+ return type === 'BOOLEAN'
276
+ ? { type, name, default: token ?? (value as boolean), ...at }
277
+ : { type, name, default: token ?? (value as string), ...at };
278
+ }
279
+
280
+ if (type === 'INSTANCE_SWAP') {
281
+ onlyKeys(sink, props, ['default'], what);
282
+ const entry = prop('default');
283
+ const value = entry?.getValue();
284
+ let def: ComponentRef | undefined;
285
+ if (entry && entry.getTag() === null && typeof value === 'string' && value !== '' && referenceTarget(value) === undefined)
286
+ def = { name: value };
287
+ else if (entry && entry.getTag() === 'key' && typeof value === 'string' && value !== '') def = { key: value };
288
+ else report(sink, `${what} needs default=<a component name> or default=(key)"<published key>"`, entry ?? node);
289
+ const preferredValues = readPreferred(sink, node, what);
290
+ if (name === undefined || !def) return undefined;
291
+ return { type, name, default: def, ...(preferredValues ? { preferredValues } : {}), ...at };
292
+ }
293
+
294
+ // SLOT
295
+ const keys = ['description', 'minChildren', 'maxChildren', 'allowPreferredValuesOnly', 'stretchChildOnInsert', 'displayEmptyByDefault'];
296
+ onlyKeys(sink, props, keys, what);
297
+ const slot: Extract<Property, { type: 'SLOT' }> = { type: 'SLOT', name: name ?? '', ...at };
298
+ const description = prop('description');
299
+ if (description) {
300
+ const text = description.getValue();
301
+ if (description.getTag() === null && typeof text === 'string') slot.description = text;
302
+ else report(sink, `${what}: description takes text`, description);
303
+ }
304
+ const settings: SlotSettings = {};
305
+ for (const key of ['minChildren', 'maxChildren'] as const) {
306
+ const entry = prop(key);
307
+ const n = entry ? readCount(sink, entry, `${what}: ${key}`, 0) : undefined;
308
+ if (n !== undefined) settings[key] = n;
309
+ }
310
+ for (const key of ['allowPreferredValuesOnly', 'stretchChildOnInsert', 'displayEmptyByDefault'] as const) {
311
+ const entry = prop(key);
312
+ if (!entry) continue;
313
+ const flag = entry.getValue();
314
+ if (entry.getTag() === null && typeof flag === 'boolean') settings[key] = flag;
315
+ else report(sink, `${what}: ${key} takes #true or #false, not ${show(entry.getTag(), flag)}`, entry);
316
+ }
317
+ if (settings.minChildren !== undefined && settings.maxChildren !== undefined && settings.minChildren > settings.maxChildren) {
318
+ report(sink, `${what}: minChildren ${settings.minChildren} is above maxChildren ${settings.maxChildren}`, node);
319
+ }
320
+ if (Object.keys(settings).length) slot.slotSettings = settings;
321
+ const preferredValues = readPreferred(sink, node, what);
322
+ if (preferredValues) slot.preferredValues = preferredValues;
323
+ return name === undefined ? undefined : slot;
324
+ }
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // The file
328
+ // ---------------------------------------------------------------------------
329
+
330
+ export interface Read {
331
+ /** Absent only when the file holds no usable `component` node. */
332
+ component?: Component;
333
+ /** Where the component is named, for a problem with the file as a whole. */
334
+ componentLoc?: Loc;
335
+ /** kdlet's document, without the nodes that are Toghen's: plain JSON, ready to travel. */
336
+ document: Document;
337
+ problems: Problem[];
338
+ }
339
+
340
+ /**
341
+ * Reads a component file's text: kdlet reads the file, and the five words that are
342
+ * Toghen's are read here.
343
+ *
344
+ * Returns whatever could be read alongside every problem found, so a caller can
345
+ * show all of them at once.
346
+ */
347
+ export function readFile(text: string): Read {
348
+ const sink: Sink = [];
349
+ const read = readDocument(text, { words: HOST_WORDS });
350
+ for (const problem of read.problems)
351
+ sink.push({ severity: 'error', message: problem.message, line: problem.line, column: problem.column });
352
+ // kdlet hands back no nodes for a file that is not KDL at all, and says so.
353
+ const document: Document = { nodes: read.nodes.filter((item) => item.kind !== 'host'), problems: [] };
354
+ if (read.nodes.length === 0 && read.problems.length > 0) return { document, problems: sink };
355
+
356
+ const words: Node[] = [];
357
+ for (const item of read.nodes) if (item.kind === 'host') words.push(item.node);
358
+ const roots = words.filter((node) => node.getName() === 'component');
359
+ if (roots.length === 0) {
360
+ sink.push({ severity: 'error', message: 'the file needs one "component <Name>"' });
361
+ return { document, problems: sink };
362
+ }
363
+ if (roots.length > 1) report(sink, 'a file holds one component', roots[1]);
364
+ const root = roots[0];
365
+
366
+ const { args, props } = split(sink, root, 'component');
367
+ onlyKeys(sink, props, ['tokens', 'layout', 'across'], 'component');
368
+ for (const child of root.children?.nodes ?? []) {
369
+ report(
370
+ sink,
371
+ 'component has no body: write the description, the properties, the layers and the rest at the top of the file, beside it',
372
+ child,
373
+ );
374
+ }
375
+ const name = args[0]?.getValue();
376
+ if (args.length !== 1 || args[0].getTag() !== null || typeof name !== 'string' || name === '') {
377
+ report(sink, 'component needs its name as its only argument', args[0] ?? root);
378
+ }
379
+
380
+ const component: Component = { name: typeof name === 'string' ? name : '', properties: [], exclude: [] };
381
+ const tokens = props.find(([key]) => key === 'tokens')?.[1];
382
+ if (tokens) {
383
+ const group = tokens.getValue();
384
+ let path: string | undefined;
385
+ if (typeof group === 'string' && group.includes(INPUT_SEGMENT)) {
386
+ report(
387
+ sink,
388
+ `component: tokens=${show(tokens.getTag(), group)} takes no ${INPUT_SEGMENT} segment: the line is read once for the file, and not once for each member`,
389
+ tokens,
390
+ );
391
+ } else if (tokens.getTag() === null && typeof group === 'string' && referenceTarget(group) !== undefined) {
392
+ try {
393
+ path = referenceTarget(group)!;
394
+ parseReferencePath(path);
395
+ } catch (error) {
396
+ report(sink, `component: tokens=${show(null, group)} is not a token reference: ${(error as Error).message}`, tokens);
397
+ }
398
+ } else {
399
+ report(
400
+ sink,
401
+ `component: tokens takes a token reference in quotes, such as tokens="{button}", not ${show(tokens.getTag(), group)}`,
402
+ tokens,
403
+ );
404
+ }
405
+ if (path !== undefined) component.tokens = path;
406
+ }
407
+ const layout = props.find(([key]) => key === 'layout')?.[1];
408
+ if (layout) {
409
+ if (layout.getTag() !== null || layout.getValue() !== 'parts')
410
+ report(
411
+ sink,
412
+ `component: layout takes "parts" (one variable per part), not ${show(layout.getTag(), layout.getValue())}; leave it out for the folded layout`,
413
+ layout,
414
+ );
415
+ else if (component.tokens === undefined)
416
+ report(sink, 'component: layout=parts is the layout of the parts of a token group, so it needs tokens="{group}"', layout);
417
+ else component.layout = 'parts';
418
+ }
419
+ const across = props.find(([key]) => key === 'across')?.[1];
420
+ if (across) {
421
+ const raw = across.getValue();
422
+ if (across.getTag() !== null || typeof raw !== 'string') {
423
+ report(
424
+ sink,
425
+ `component: across takes the names of axes in quotes, separated by commas, such as across="state, size", not ${show(across.getTag(), raw)}`,
426
+ across,
427
+ );
428
+ } else {
429
+ const names = raw.split(',').map((name) => name.trim());
430
+ const seen = new Set<string>();
431
+ for (const name of names) {
432
+ if (name === '')
433
+ report(sink, `component: across=${show(null, raw)} has an empty name; write the axes' names separated by commas`, across);
434
+ else if (seen.has(name)) report(sink, `component: across=${show(null, raw)} names "${name}" twice`, across);
435
+ seen.add(name);
436
+ }
437
+ if (!names.includes('')) {
438
+ component.across = [...seen];
439
+ const loc = locOf(across);
440
+ if (loc) component.acrossLoc = loc;
441
+ }
442
+ }
443
+ }
444
+ let sawProperties = false;
445
+ let sawDescription = false;
446
+ for (const word of words) {
447
+ const kind = word.getName();
448
+ if (kind === 'description') {
449
+ const d = split(sink, word, 'description');
450
+ noChildren(sink, word, 'description');
451
+ onlyKeys(sink, d.props, [], 'description');
452
+ const text = d.args[0]?.getValue();
453
+ if (sawDescription) report(sink, 'description is written twice', word);
454
+ else if (d.args.length !== 1 || d.args[0].getTag() !== null || typeof text !== 'string')
455
+ report(sink, 'description takes one piece of text', word);
456
+ else component.description = text;
457
+ sawDescription = true;
458
+ } else if (kind === 'properties') {
459
+ const p = split(sink, word, 'properties');
460
+ noArguments(sink, p.args, 'properties');
461
+ onlyKeys(sink, p.props, [], 'properties');
462
+ if (sawProperties) report(sink, 'properties is written twice', word);
463
+ sawProperties = true;
464
+ for (const declaration of word.children?.nodes ?? []) {
465
+ const property = readProperty(sink, declaration);
466
+ if (property) component.properties.push(property);
467
+ }
468
+ } else if (kind === 'exclude' || kind === 'include') {
469
+ noChildren(sink, word, kind);
470
+ const combination: Exclusion = { combination: readCombination(sink, word, kind) };
471
+ const loc = locOf(word);
472
+ if (loc) combination.loc = loc;
473
+ if (Object.keys(combination.combination).length > 0) {
474
+ if (kind === 'exclude') component.exclude.push(combination);
475
+ else component.include = [...(component.include ?? []), combination];
476
+ }
477
+ }
478
+ }
479
+ return { component, ...(locOf(root) ? { componentLoc: locOf(root) } : {}), document, problems: sink };
480
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Where Toghen's sets and artboards go on a page. Pure: this is tested without Figma.
3
+ *
4
+ * Toghen records where it put each set and artboard (a `placed` stamp). One whose position still equals its stamp is
5
+ * Toghen's to place, and after a run those are stacked again, so a set that grew pushes the ones below it down. One
6
+ * whose position differs was moved by hand and is the designer's: never moved, and the stack flows around it.
7
+ */
8
+
9
+ /** The gap between two sets of the stack. */
10
+ export const SET_GAP = 100;
11
+
12
+ /** How far a node may be from its stamp and still count as where Toghen put it. */
13
+ const SAME = 0.5;
14
+
15
+ /** A top-level node of a page, as plain data. */
16
+ export interface TopLevel {
17
+ id: string;
18
+ name: string;
19
+ x: number;
20
+ y: number;
21
+ width: number;
22
+ height: number;
23
+ /** A component set or an artboard's frame that Toghen made. */
24
+ toghen: boolean;
25
+ /** Its `placed` stamp, when it has one. */
26
+ placed?: { x: number; y: number };
27
+ /** Made by this run, and its place in the order they were made: it joins the stack at its end. */
28
+ created?: number;
29
+ }
30
+
31
+ export interface Plan {
32
+ /** The nodes whose position changes. */
33
+ moves: { id: string; name: string; x: number; y: number }[];
34
+ /** The nodes whose `placed` stamp is written, at the position they have after the moves. */
35
+ stamps: { id: string; x: number; y: number }[];
36
+ /** What the restack leaves for the person to see: a set moved by hand that overlaps something. */
37
+ notes: string[];
38
+ }
39
+
40
+ /** The page's nodes, by what the restack does with each. */
41
+ export interface Sorted {
42
+ /** Toghen's, and those from before positions were recorded that overlap another of Toghen's: in their current order, top to bottom and then left to right. */
43
+ stack: TopLevel[];
44
+ /** Made by this run, in the order they were made. */
45
+ created: TopLevel[];
46
+ /** Moved by hand: left where they are. */
47
+ byHand: TopLevel[];
48
+ /** From before positions were recorded, overlapping nothing: left where they are and stamped there. */
49
+ inPlace: TopLevel[];
50
+ /** Everything else at the top level. */
51
+ others: TopLevel[];
52
+ }
53
+
54
+ const overlaps = (a: Pick<TopLevel, 'x' | 'y' | 'width' | 'height'>, b: Pick<TopLevel, 'x' | 'y' | 'width' | 'height'>): boolean =>
55
+ a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
56
+
57
+ const round = (value: number): number => Math.round(value * 100) / 100;
58
+ const quoted = (names: string[]): string => names.map((name) => `"${name}"`).join(', ');
59
+
60
+ export function sortTopLevel(nodes: TopLevel[]): Sorted {
61
+ const sorted: Sorted = { stack: [], created: [], byHand: [], inPlace: [], others: [] };
62
+ // A set made by this run sits at a placeholder position, which says nothing about what it overlaps.
63
+ const known = nodes.filter((node) => node.toghen && node.created === undefined);
64
+ for (const node of nodes) {
65
+ if (!node.toghen) sorted.others.push(node);
66
+ else if (node.created !== undefined) sorted.created.push(node);
67
+ else if (node.placed)
68
+ (Math.abs(node.x - node.placed.x) <= SAME && Math.abs(node.y - node.placed.y) <= SAME ? sorted.stack : sorted.byHand).push(node);
69
+ else (known.some((other) => other !== node && overlaps(node, other)) ? sorted.stack : sorted.inPlace).push(node);
70
+ }
71
+ sorted.stack.sort((a, b) => a.y - b.y || a.x - b.x);
72
+ sorted.created.sort((a, b) => (a.created as number) - (b.created as number));
73
+ return sorted;
74
+ }
75
+
76
+ /**
77
+ * The restack of one page: the stack's left edges on the leftmost of them, the first where the topmost is, each next
78
+ * `SET_GAP` below the one before; one that would overlap a node outside the stack goes below that node instead. Made by
79
+ * this run, a set joins at the end. With no set in the stack yet, it starts at the top left of what else is on the page.
80
+ */
81
+ export function restack(nodes: TopLevel[]): Plan {
82
+ const sorted = sortTopLevel(nodes);
83
+ const fixed = [...sorted.byHand, ...sorted.inPlace, ...sorted.others];
84
+ const anchor = sorted.stack.length > 0 ? sorted.stack : fixed;
85
+ const left = anchor.length > 0 ? Math.min(...anchor.map((node) => node.x)) : 0;
86
+ let y = anchor.length > 0 ? Math.min(...anchor.map((node) => node.y)) : 0;
87
+
88
+ const plan: Plan = { moves: [], stamps: [], notes: [] };
89
+ const final: TopLevel[] = [];
90
+ for (const node of [...sorted.stack, ...sorted.created]) {
91
+ let at = y;
92
+ for (;;) {
93
+ const hit = fixed.find((other) => overlaps({ x: left, y: at, width: node.width, height: node.height }, other));
94
+ if (!hit) break;
95
+ at = hit.y + hit.height + SET_GAP;
96
+ }
97
+ const x = round(left);
98
+ at = round(at);
99
+ if (Math.abs(node.x - x) > 0.001 || Math.abs(node.y - at) > 0.001) plan.moves.push({ id: node.id, name: node.name, x, y: at });
100
+ plan.stamps.push({ id: node.id, x, y: at });
101
+ final.push({ ...node, x, y: at });
102
+ y = at + node.height + SET_GAP;
103
+ }
104
+ for (const node of sorted.inPlace) plan.stamps.push({ id: node.id, x: node.x, y: node.y });
105
+
106
+ for (const hand of sorted.byHand) {
107
+ const hits = [...final, ...fixed].filter((other) => other.id !== hand.id && overlaps(hand, other));
108
+ if (hits.length > 0)
109
+ plan.notes.push(`"${hand.name}" was moved by hand and overlaps ${quoted(hits.map((hit) => hit.name))}; it was left where it is.`);
110
+ }
111
+ return plan;
112
+ }
113
+
114
+ /** What a dry run says of a page: which sets a run would restack, and which were moved by hand and would stay. No positions: the sizes of the sets it would build are not known until they are built. */
115
+ export function describeStack(page: string, nodes: TopLevel[]): string[] {
116
+ const sorted = sortTopLevel(nodes);
117
+ const lines: string[] = [];
118
+ if (sorted.stack.length > 0) lines.push(`page "${page}": a run would restack ${quoted(sorted.stack.map((node) => node.name))}`);
119
+ if (sorted.byHand.length > 0)
120
+ lines.push(
121
+ `page "${page}": ${quoted(sorted.byHand.map((node) => node.name))} ${sorted.byHand.length === 1 ? 'was' : 'were'} moved by hand and would be left where ${sorted.byHand.length === 1 ? 'it is' : 'they are'}`,
122
+ );
123
+ return lines;
124
+ }