@toghen/figma 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +18 -2
- package/cli/bin.mjs +19 -0
- package/cli/bridge.ts +243 -0
- package/cli/check.ts +62 -0
- package/cli/figcraft.ts +764 -0
- package/cli/figcraftApply.ts +1067 -0
- package/cli/figcraftFile.ts +476 -0
- package/cli/figcraftLibrary.ts +147 -0
- package/cli/figcraftRecord.ts +121 -0
- package/cli/figcraftScene.ts +661 -0
- package/cli/main.ts +218 -0
- package/cli/members.ts +65 -0
- package/cli/run.ts +756 -0
- package/component-files.md +665 -0
- package/dist/code.js +24161 -0
- package/dist/manifest.json +23 -0
- package/dist/ui.html +6918 -0
- package/guide.md +985 -0
- package/main/code.ts +50 -0
- package/main/components/apply.ts +704 -0
- package/main/components/endOfRun.ts +168 -0
- package/main/components/errors.ts +17 -0
- package/main/components/fieldWriter.ts +497 -0
- package/main/components/file.ts +263 -0
- package/main/components/receipt.ts +228 -0
- package/main/components/resolve.ts +1193 -0
- package/main/components/run.ts +273 -0
- package/main/components/snapshot.ts +341 -0
- package/main/components/stamp.ts +312 -0
- package/main/components/update.ts +465 -0
- package/main/dispatcher.ts +33 -0
- package/main/engines/batchRunner.ts +200 -0
- package/main/engines/ioAdapter.ts +132 -0
- package/main/engines/operationExecutor.ts +512 -0
- package/main/engines/propertyMapper.ts +221 -0
- package/main/engines/queryEngine.ts +45 -0
- package/main/engines/styleSyncEngine.ts +444 -0
- package/main/engines/variableSyncEngine.ts +561 -0
- package/main/handlers/auditSelection.ts +60 -0
- package/main/handlers/boundLayers.ts +51 -0
- package/main/handlers/cancelOperation.ts +18 -0
- package/main/handlers/executeApplyBindings.ts +69 -0
- package/main/handlers/executeScaffold.ts +59 -0
- package/main/handlers/executeStyleSync.ts +121 -0
- package/main/handlers/executeVariableSync.ts +82 -0
- package/main/handlers/exportDocument.ts +114 -0
- package/main/handlers/getSelection.ts +43 -0
- package/main/handlers/index.ts +30 -0
- package/main/handlers/inspectCorrespondence.ts +172 -0
- package/main/handlers/loadConventions.ts +86 -0
- package/main/handlers/loadDocument.ts +253 -0
- package/main/handlers/lock.ts +270 -0
- package/main/handlers/planApply.ts +157 -0
- package/main/handlers/planApplyBindings.ts +79 -0
- package/main/handlers/planScaffold.ts +84 -0
- package/main/handlers/planStyleSync.ts +49 -0
- package/main/handlers/planVariableSync.ts +49 -0
- package/main/handlers/readSelectionBindings.ts +160 -0
- package/main/handlers/resizeUi.ts +105 -0
- package/main/handlers/runComponent.ts +65 -0
- package/main/handlers/templates.ts +462 -0
- package/main/handlers/tokenGraph.ts +122 -0
- package/main/runtimeState.ts +66 -0
- package/main/utils/abortController.ts +11 -0
- package/main/utils/boundVariables.ts +84 -0
- package/main/utils/buildPayloads.ts +85 -0
- package/main/utils/correspondence.ts +658 -0
- package/main/utils/designSource.ts +122 -0
- package/main/utils/figmaCollectionIndex.ts +120 -0
- package/main/utils/figmaStyleAdapter.ts +268 -0
- package/main/utils/figmaTokenAdapter.ts +255 -0
- package/main/utils/flattenDesignSystem.ts +38 -0
- package/main/utils/folds.ts +203 -0
- package/main/utils/fontStack.ts +57 -0
- package/main/utils/lists.ts +4 -0
- package/main/utils/lockStore.ts +72 -0
- package/main/utils/moves.ts +67 -0
- package/main/utils/pageRoots.ts +58 -0
- package/main/utils/planSymbols.ts +96 -0
- package/main/utils/purpose.ts +351 -0
- package/main/utils/recentStore.ts +166 -0
- package/main/utils/resolverModes.ts +271 -0
- package/main/utils/rootRecord.ts +137 -0
- package/main/utils/shardedData.ts +142 -0
- package/main/utils/standing.ts +106 -0
- package/main/utils/styleTarget.ts +51 -0
- package/main/utils/sum.ts +612 -0
- package/main/utils/symbolicIdRegistry.ts +26 -0
- package/main/utils/syncState.ts +133 -0
- package/main/utils/templateStore.ts +73 -0
- package/main/utils/tokenPath.ts +28 -0
- package/main/utils/tokenStamps.ts +91 -0
- package/manifest.json +15 -0
- package/package.json +64 -4
- package/reference.md +1609 -0
- package/shared/components/artboard.ts +126 -0
- package/shared/components/axes.ts +420 -0
- package/shared/components/bindings.ts +64 -0
- package/shared/components/blueprint.ts +248 -0
- package/shared/components/check.ts +705 -0
- package/shared/components/diff.ts +668 -0
- package/shared/components/fields.ts +404 -0
- package/shared/components/groups.ts +306 -0
- package/shared/components/index.ts +140 -0
- package/shared/components/members.ts +280 -0
- package/shared/components/model.ts +273 -0
- package/shared/components/order.ts +53 -0
- package/shared/components/read.ts +480 -0
- package/shared/components/stack.ts +124 -0
- package/shared/components/steps.ts +186 -0
- package/shared/components/validate.ts +565 -0
- package/shared/conventions.ts +28 -0
- package/shared/inputs.ts +40 -0
- package/shared/operations.ts +233 -0
- package/shared/pendingNames.ts +39 -0
- package/shared/protocol.ts +588 -0
- package/shared/tokenTypeMap.ts +21 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An artboard file: `artboard <Name>` and one root `FRAME`, in the component file's vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* A component file says what a component is, and is expanded once per combination of variant values. An
|
|
5
|
+
* artboard file says what a frame on a page holds: instances of components, with their properties set and their
|
|
6
|
+
* slots filled, and it is evaluated once, with the inputs the command line hands it. The same kdlet reads and
|
|
7
|
+
* evaluates it, and the same checks refuse what is wrong with a layer; what differs is the header, the words
|
|
8
|
+
* `slot`, and that `each`, `define` and `+call` are allowed, since inputs give them a list to repeat.
|
|
9
|
+
*
|
|
10
|
+
* The file is read into the same shape a component file is (`ComponentFile`): a name, and one member, whose tree is
|
|
11
|
+
* the frame. So the engine's snapshot, diff and apply serve both, and an artboard is found again by its stamp.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Node } from '@bgotink/kdl';
|
|
15
|
+
import type { Document, Item } from 'kdlet';
|
|
16
|
+
import { evaluate, read as readDocument } from 'kdlet';
|
|
17
|
+
import { checkArtboardRoots } from './check.js';
|
|
18
|
+
import type { ComponentFile } from './index.js';
|
|
19
|
+
import { vocabulary, toArtboardLayer } from './members.js';
|
|
20
|
+
import type { Member, Problem } from './model.js';
|
|
21
|
+
import { HOST_WORDS, locOf, report, split } from './read.js';
|
|
22
|
+
import { validateArtboard } from './validate.js';
|
|
23
|
+
|
|
24
|
+
/** The top-level word that makes a file an artboard file. */
|
|
25
|
+
export const ARTBOARD_WORD = 'artboard';
|
|
26
|
+
|
|
27
|
+
const WORDS = [ARTBOARD_WORD, ...HOST_WORDS];
|
|
28
|
+
|
|
29
|
+
/** Whether the text is an artboard file: it has an `artboard` node at the top. */
|
|
30
|
+
export function isArtboardText(text: string): boolean {
|
|
31
|
+
return readDocument(text, { words: WORDS }).nodes.some((item) => item.kind === 'host' && item.name === ARTBOARD_WORD);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What an artboard file cannot say: a guard no host is there to supply. */
|
|
35
|
+
function checkDocument(items: Item[], problems: Problem[]): void {
|
|
36
|
+
for (const item of items) {
|
|
37
|
+
if (item.kind === 'guard' && item.body === undefined) {
|
|
38
|
+
problems.push({
|
|
39
|
+
severity: 'error',
|
|
40
|
+
message: `guard "${item.name}" has no body, which marks a guard the host supplies; an artboard file has no host to supply one`,
|
|
41
|
+
line: item.loc.line,
|
|
42
|
+
column: item.loc.column,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if ('children' in item) checkDocument(item.children, problems);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const position = (problem: Problem): number => (problem.line === undefined ? Infinity : problem.line * 100000 + (problem.column ?? 0));
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reads, evaluates and validates one artboard file against its inputs.
|
|
53
|
+
*
|
|
54
|
+
* Every problem is reported, not the first. The tree is checked only when kdlet found nothing wrong in the
|
|
55
|
+
* evaluation (a missing input is kdlet's, with the line where it would be shown).
|
|
56
|
+
*/
|
|
57
|
+
export function readArtboardFile(text: string, inputs: Record<string, unknown> = {}): ComponentFile {
|
|
58
|
+
const sink: Problem[] = [];
|
|
59
|
+
const read = readDocument(text, { words: WORDS });
|
|
60
|
+
for (const problem of read.problems)
|
|
61
|
+
sink.push({ severity: 'error', message: problem.message, line: problem.line, column: problem.column });
|
|
62
|
+
const document: Document = { nodes: read.nodes.filter((item) => item.kind !== 'host'), problems: [] };
|
|
63
|
+
if (read.nodes.length === 0 && read.problems.length > 0) return { document, members: [], problems: sink };
|
|
64
|
+
|
|
65
|
+
const words: Node[] = [];
|
|
66
|
+
for (const item of read.nodes) if (item.kind === 'host') words.push(item.node);
|
|
67
|
+
const headers = words.filter((word) => word.getName() === ARTBOARD_WORD);
|
|
68
|
+
if (headers.length === 0) {
|
|
69
|
+
sink.push({ severity: 'error', message: 'the file needs one "artboard <Name>"' });
|
|
70
|
+
return { document, members: [], problems: sink };
|
|
71
|
+
}
|
|
72
|
+
if (headers.length > 1) report(sink, 'a file holds one artboard', headers[1]);
|
|
73
|
+
for (const word of words) {
|
|
74
|
+
const name = word.getName();
|
|
75
|
+
if (name === ARTBOARD_WORD) continue;
|
|
76
|
+
report(
|
|
77
|
+
sink,
|
|
78
|
+
name === 'component'
|
|
79
|
+
? 'a file holds an artboard or a component, not both'
|
|
80
|
+
: `${name} belongs to a component file: an artboard has no component properties, variant axes or members`,
|
|
81
|
+
word,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const header = headers[0];
|
|
86
|
+
const { args, props } = split(sink, header, ARTBOARD_WORD);
|
|
87
|
+
for (const [key, entry] of props) report(sink, `artboard has no "${key}"`, entry);
|
|
88
|
+
for (const child of header.children?.nodes ?? []) {
|
|
89
|
+
report(sink, 'artboard has no body: write the root FRAME beside it, at the top of the file', child);
|
|
90
|
+
}
|
|
91
|
+
const name = args[0]?.getValue();
|
|
92
|
+
const named = args.length === 1 && args[0].getTag() === null && typeof name === 'string' && name !== '';
|
|
93
|
+
if (!named) report(sink, 'artboard needs its name as its only argument', args[0] ?? header);
|
|
94
|
+
const at = locOf(header) ?? { line: 1, column: 1 };
|
|
95
|
+
|
|
96
|
+
checkDocument(document.nodes, sink);
|
|
97
|
+
let members: Member[] = [];
|
|
98
|
+
const artboardName = typeof name === 'string' ? name : '';
|
|
99
|
+
if (!sink.some((problem) => problem.severity === 'error')) {
|
|
100
|
+
const { tree, problems } = evaluate(document, { vocabulary, inputs });
|
|
101
|
+
sink.push(
|
|
102
|
+
...problems.map((problem) => ({ severity: 'error' as const, message: problem.message, line: problem.line, column: problem.column })),
|
|
103
|
+
);
|
|
104
|
+
// A tree kdlet had problems with is not checked further: what is wrong in it would be said twice.
|
|
105
|
+
if (problems.length === 0) {
|
|
106
|
+
sink.push(...checkArtboardRoots(tree, artboardName, at));
|
|
107
|
+
if (!sink.some((problem) => problem.severity === 'error')) {
|
|
108
|
+
const root = toArtboardLayer(tree[0]);
|
|
109
|
+
sink.push(...validateArtboard(root));
|
|
110
|
+
members = [{ name: artboardName, combination: {}, tree: root }];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (sink.some((problem) => problem.severity === 'error')) members = [];
|
|
115
|
+
|
|
116
|
+
// A stable sort: problems on one line keep the order they were found in.
|
|
117
|
+
const ordered = sink.map((problem, index) => ({ problem, index }));
|
|
118
|
+
ordered.sort((a, b) => position(a.problem) - position(b.problem) || a.index - b.index);
|
|
119
|
+
return {
|
|
120
|
+
artboard: true,
|
|
121
|
+
component: { name: artboardName, properties: [], exclude: [] },
|
|
122
|
+
document,
|
|
123
|
+
members,
|
|
124
|
+
problems: ordered.map((entry) => entry.problem),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Axes from the tokens: a variant axis that takes its values from a modifier of the
|
|
3
|
+
* design system, and the axes a component gets from the parts of the tokens it names.
|
|
4
|
+
*
|
|
5
|
+
* Coordination with Figma stays in variable sync and in component files. The
|
|
6
|
+
* resolver gives the modes (variable sync makes a collection per modifier, a mode
|
|
7
|
+
* per context); a component file may take an axis from a modifier, so that a context
|
|
8
|
+
* added to the document adds a member on the next run, and each member pins the
|
|
9
|
+
* modifier's collection to its context. A component that names its token group gets
|
|
10
|
+
* an axis for every axis of the parts vocabulary its subgroups use, with `rest` as the default
|
|
11
|
+
* value. An axis that folded into modes pins the collection named after it; one that stayed in the
|
|
12
|
+
* path pins nothing and chooses the part's variables. Worlds, theme and density, are never axes of their own: a
|
|
13
|
+
* designer sets them on the frame an instance sits in, as CSS takes them from an ancestor.
|
|
14
|
+
*
|
|
15
|
+
* The design system is reached through `DesignSource`, which holds no `@toghen/lib`:
|
|
16
|
+
* this file runs wherever a file is read, and the answers come from the plugin
|
|
17
|
+
* (`main/utils/designSource.ts`), which asks lib, from the resolver document it has loaded. Once resolved, an axis is a plain `VARIANT`
|
|
18
|
+
* with its values written out and the modifier it came from, so the members
|
|
19
|
+
* are built from the component alone.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { referencesIn } from '@toghen/lib';
|
|
23
|
+
import type { Json, OutNode } from 'kdlet';
|
|
24
|
+
import type { Component, GroupInfo, Layer, Loc, Problem, Property } from './model.js';
|
|
25
|
+
|
|
26
|
+
/** The default value of a part axis, and the mode a folded axis starts with. */
|
|
27
|
+
export const REST = 'rest';
|
|
28
|
+
|
|
29
|
+
/** One axis of the parts vocabulary a component's token group uses, as the design system lays it out. */
|
|
30
|
+
export interface PartAxis {
|
|
31
|
+
axis: string;
|
|
32
|
+
/** The part keys the component uses on this axis, without `rest`. */
|
|
33
|
+
values: string[];
|
|
34
|
+
/** Folded into a collection of modes, or left in the path as one variable per part. */
|
|
35
|
+
folded: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What a component file may ask of the design system it is read against. */
|
|
39
|
+
export interface DesignSource {
|
|
40
|
+
/**
|
|
41
|
+
* The contexts of a modifier that Figma gets, the default first: all of them unless the modifier lists
|
|
42
|
+
* the ones Figma gets (`figma.include`). `undefined` when the design system has no such modifier.
|
|
43
|
+
*/
|
|
44
|
+
contexts(modifier: string): string[] | undefined;
|
|
45
|
+
/** The contexts of a modifier that Figma does not get; empty when it gets them all. Absent from a source that cannot say. */
|
|
46
|
+
leftOut?(modifier: string): string[] | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* The axes of the parts vocabulary the subgroups of a group use, in the vocabulary's order, each folded or
|
|
49
|
+
* not; empty for a group that is no component. `layoutParts` lays every axis of this component out in the path
|
|
50
|
+
* (`layout=parts`). `undefined` when the group names no token and no group.
|
|
51
|
+
*/
|
|
52
|
+
partsOf(group: string, layoutParts?: boolean): PartAxis[] | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* The tokens one level down in a group, for the bindings `tokens=` infers from their keys:
|
|
55
|
+
* `'token'` when the path is a token, not a group; `undefined` when it names nothing. A `selection`
|
|
56
|
+
* (axis to part key, for the axes left in the path) adds the keys of the part groups it selects, which
|
|
57
|
+
* outrank the group's own. Absent
|
|
58
|
+
* from a source that cannot say, in which case no binding is inferred.
|
|
59
|
+
*/
|
|
60
|
+
group?(path: string, selection?: Record<string, string>): GroupInfo | 'token' | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* The names of a group's children, tokens and groups alike, in the document's order, for an axis that takes its
|
|
63
|
+
* values from a group (`VARIANT hue from="{hue}"`): `'token'` when the path is a token; `undefined` when it names
|
|
64
|
+
* nothing. Absent from a source that cannot say.
|
|
65
|
+
*/
|
|
66
|
+
children?(path: string): string[] | 'token' | undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type Variant = Extract<Property, { type: 'VARIANT' }>;
|
|
70
|
+
|
|
71
|
+
/** A character a variant axis or value cannot hold, and why. */
|
|
72
|
+
const AXIS_NAME = /[,=.]/;
|
|
73
|
+
const VALUE = /[,=]/;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Fills in the axes a file leaves to the design system, in place: the values of every
|
|
77
|
+
* `VARIANT … (modifier)"…"`, and the axis for every part axis `tokens=` brings in.
|
|
78
|
+
*
|
|
79
|
+
* Returns the problems it found. An axis it could not fill is left with no values, so
|
|
80
|
+
* the caller does not go on to check a component whose axes it does not know.
|
|
81
|
+
*/
|
|
82
|
+
export function resolveAxes(component: Component, source: DesignSource | undefined, componentLoc?: Loc): Problem[] {
|
|
83
|
+
const problems: Problem[] = [];
|
|
84
|
+
const fail = (message: string, loc?: Loc): void => {
|
|
85
|
+
problems.push({ severity: 'error', message, ...(loc ?? {}) });
|
|
86
|
+
};
|
|
87
|
+
const needsSource = (what: string, loc?: Loc): void =>
|
|
88
|
+
fail(`${what}, and no design system is loaded; load its resolver document in the plugin, or pass it to the command line`, loc);
|
|
89
|
+
|
|
90
|
+
// The design system may refuse to answer (a document it cannot build the sum of): that is the file's problem to report, not a crash.
|
|
91
|
+
const ask = <T>(question: () => T, loc?: Loc): T | undefined => {
|
|
92
|
+
try {
|
|
93
|
+
return question();
|
|
94
|
+
} catch (error) {
|
|
95
|
+
fail(`the design system could not be read: ${error instanceof Error ? error.message : String(error)}`, loc);
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const variants = component.properties.filter((property): property is Variant => property.type === 'VARIANT');
|
|
101
|
+
for (const axis of variants) {
|
|
102
|
+
if (axis.from !== undefined) {
|
|
103
|
+
const group = axis.from;
|
|
104
|
+
const what = `VARIANT "${axis.name}" takes its values from the group "{${group}}"`;
|
|
105
|
+
if (!source?.children) {
|
|
106
|
+
needsSource(what, axis.loc);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
let children: string[] | 'token' | undefined;
|
|
110
|
+
try {
|
|
111
|
+
children = source.children(group);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
fail(`${what}, but the design system could not be read: ${error instanceof Error ? error.message : String(error)}`, axis.loc);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (children === undefined) {
|
|
117
|
+
fail(`${what}, which names no token and no group of the design system`, axis.loc);
|
|
118
|
+
} else if (children === 'token') {
|
|
119
|
+
fail(`${what}, which is a token, not a group: the values are the names of a group's children`, axis.loc);
|
|
120
|
+
} else {
|
|
121
|
+
const bad = children.find((child) => child === '' || VALUE.test(child));
|
|
122
|
+
if (bad !== undefined)
|
|
123
|
+
fail(
|
|
124
|
+
`${what}, and the child "${bad}" cannot be a variant value: a value cannot be empty or contain "," or "=", which member names use`,
|
|
125
|
+
axis.loc,
|
|
126
|
+
);
|
|
127
|
+
else axis.values = children;
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (axis.modifier === undefined) continue;
|
|
132
|
+
const what = `VARIANT "${axis.name}" takes its values from the modifier "${axis.modifier}"`;
|
|
133
|
+
if (!source) {
|
|
134
|
+
needsSource(what, axis.loc);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const modifier = axis.modifier;
|
|
138
|
+
let contexts: string[] | undefined;
|
|
139
|
+
try {
|
|
140
|
+
contexts = source.contexts(modifier);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
fail(`${what}, but the design system could not be read: ${error instanceof Error ? error.message : String(error)}`, axis.loc);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (!contexts) {
|
|
146
|
+
fail(`${what}, which the design system does not have`, axis.loc);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
// The checks a written value gets, on the contexts the document gives.
|
|
150
|
+
const bad = contexts.find((context) => context === '' || VALUE.test(context));
|
|
151
|
+
if (bad !== undefined) {
|
|
152
|
+
fail(
|
|
153
|
+
`${what}, and the context "${bad}" cannot be a variant value: a value cannot be empty or contain "," or "=", which member names use`,
|
|
154
|
+
axis.loc,
|
|
155
|
+
);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
axis.values = contexts;
|
|
159
|
+
const left = ask(() => source.leftOut?.(modifier), axis.loc);
|
|
160
|
+
if (left && left.length > 0) axis.leftOut = left;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (component.tokens !== undefined) {
|
|
164
|
+
const what = `component tokens="{${component.tokens}}"`;
|
|
165
|
+
if (!source) {
|
|
166
|
+
needsSource(`${what} takes its axes from the tokens it names`, componentLoc);
|
|
167
|
+
} else {
|
|
168
|
+
const group = component.tokens;
|
|
169
|
+
const found = ask(() => source.partsOf(group, component.layout === 'parts'), componentLoc);
|
|
170
|
+
if (found === undefined) {
|
|
171
|
+
// Either the design system could not be read (said above) or the group names nothing.
|
|
172
|
+
if (!problems.some((problem) => /could not be read/.test(problem.message)))
|
|
173
|
+
fail(`${what} names no token and no group of the design system`, componentLoc);
|
|
174
|
+
} else {
|
|
175
|
+
for (const { axis, values, folded } of found) {
|
|
176
|
+
// A declared axis of the same name wins: it is marked as a part axis and keeps its values (a value with no part group reads the rest).
|
|
177
|
+
const declared = variants.find(
|
|
178
|
+
(variant) => variant.name === axis && variant.modifier === undefined && variant.from === undefined,
|
|
179
|
+
);
|
|
180
|
+
if (declared) {
|
|
181
|
+
if (folded) {
|
|
182
|
+
fail(
|
|
183
|
+
`VARIANT "${axis}" is declared and also comes from the parts of "${group}", which fold it into modes, and a mode cannot hold the declared values that have no part. Drop the declared axis, or lay the component out with layout=parts`,
|
|
184
|
+
declared.loc ?? componentLoc,
|
|
185
|
+
);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
declared.part = true;
|
|
189
|
+
const left = values.filter((value) => !declared.values.includes(value));
|
|
190
|
+
if (left.length > 0) {
|
|
191
|
+
const keys = left.map((value) => `"${value}"`).join(', ');
|
|
192
|
+
problems.push({
|
|
193
|
+
severity: 'warning',
|
|
194
|
+
message: `VARIANT "${axis}" is declared and also comes from the parts of "${group}"; the declared values win, and ${keys} ${left.length === 1 ? 'is' : 'are'} left out.`,
|
|
195
|
+
...(componentLoc ?? {}),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (AXIS_NAME.test(axis) || values.some((value) => value === '' || value === REST || VALUE.test(value))) {
|
|
201
|
+
fail(
|
|
202
|
+
`${what} has parts on the axis "${axis}", which cannot be a variant: an axis name cannot contain ",", "=" or ".", and a part key cannot be "${REST}" or contain "," or "="`,
|
|
203
|
+
componentLoc,
|
|
204
|
+
);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
// After the declared ones, in the vocabulary's order. A folded axis pins the collection named after it.
|
|
208
|
+
component.properties.push({
|
|
209
|
+
type: 'VARIANT',
|
|
210
|
+
name: axis,
|
|
211
|
+
values: [REST, ...values],
|
|
212
|
+
part: true,
|
|
213
|
+
...(folded ? { modifier: axis } : {}),
|
|
214
|
+
...(componentLoc ? { loc: componentLoc } : {}),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return problems;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The part keys a combination selects on the axes left in the path, axis to key, `rest` left out: the part
|
|
225
|
+
* groups whose tokens a member's layers bind (`variant=primary` binds `button.primary.backgroundColor`).
|
|
226
|
+
*/
|
|
227
|
+
export function selectionOf(component: Component, combination: Record<string, string>): Record<string, string> {
|
|
228
|
+
const selection: Record<string, string> = {};
|
|
229
|
+
for (const property of component.properties) {
|
|
230
|
+
if (property.type === 'VARIANT' && property.part && property.modifier === undefined && combination[property.name] !== REST)
|
|
231
|
+
selection[property.name] = combination[property.name];
|
|
232
|
+
}
|
|
233
|
+
return selection;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The key a group's plan is stored under for a selection: `button|variant=primary`, or the group itself when the selection is empty. */
|
|
237
|
+
export function planKey(group: string, selection: Record<string, string>): string {
|
|
238
|
+
const keys = Object.keys(selection);
|
|
239
|
+
return keys.length === 0 ? group : `${group}|${keys.map((axis) => `${axis}=${selection[axis]}`).join(',')}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The pins a combination puts on every member's root: modifier name to the context, `modes state=hover`. */
|
|
243
|
+
export function pinsOf(component: Component, combination: Record<string, string>): Record<string, string> {
|
|
244
|
+
const pins: Record<string, string> = {};
|
|
245
|
+
for (const property of component.properties) {
|
|
246
|
+
if (property.type === 'VARIANT' && property.modifier !== undefined) pins[property.modifier] = combination[property.name];
|
|
247
|
+
}
|
|
248
|
+
return pins;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* A `modes <modifier>=<context>` that names a context Figma does not get (the modifier's `figma.include`
|
|
253
|
+
* leaves it out): the collection has no such mode, so the layer could not be pinned to it. An error naming
|
|
254
|
+
* it, at the line of the field, for every layer of the tree. A source that cannot say finds nothing.
|
|
255
|
+
*/
|
|
256
|
+
export function checkLeftOutModes(tree: Layer, source: DesignSource | undefined): Problem[] {
|
|
257
|
+
const problems: Problem[] = [];
|
|
258
|
+
if (!source?.leftOut) return problems;
|
|
259
|
+
const walk = (layer: Layer): void => {
|
|
260
|
+
const modes = layer.fields.modes;
|
|
261
|
+
if (typeof modes === 'object' && modes !== null && !Array.isArray(modes)) {
|
|
262
|
+
for (const [modifier, context] of Object.entries(modes as Record<string, string>)) {
|
|
263
|
+
let left: string[] | undefined;
|
|
264
|
+
try {
|
|
265
|
+
left = source.leftOut?.(modifier);
|
|
266
|
+
} catch {
|
|
267
|
+
// A design system that cannot be read is said by the axes, once.
|
|
268
|
+
}
|
|
269
|
+
if (left?.includes(context)) {
|
|
270
|
+
problems.push({
|
|
271
|
+
severity: 'error',
|
|
272
|
+
message: `modes ${modifier}=${context}: "${context}" is a context of the modifier "${modifier}" that Figma does not get, since its figma.include leaves it out`,
|
|
273
|
+
...(layer.fieldLoc?.modes ?? layer.loc ?? {}),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
for (const child of layer.children) walk(child);
|
|
279
|
+
};
|
|
280
|
+
walk(tree);
|
|
281
|
+
return problems;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// A segment of a reference from an axis
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
/** The text that makes a path segment of a reference the value of an axis: `{hue.(input)hue.soft}`. */
|
|
289
|
+
export const INPUT_SEGMENT = '(input)';
|
|
290
|
+
|
|
291
|
+
/** `hue=rose, look=soft`: the values an expansion took, for a message. */
|
|
292
|
+
export const viaAxes = (axes: string[], combination: Record<string, string>): string =>
|
|
293
|
+
axes.map((axis) => `${axis}=${combination[axis]}`).join(', ');
|
|
294
|
+
|
|
295
|
+
/** A member's tree with its references' axis segments replaced, as kdlet returns it, and the axes each field took. */
|
|
296
|
+
export interface Expanded extends OutNode {
|
|
297
|
+
fromAxes?: Record<string, string[]>;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The path of one reference with its `(input)<Axis>` segments replaced by the member's value of the axis.
|
|
302
|
+
*
|
|
303
|
+
* An axis name holds no dot, so a segment ends at the next dot or at the end; a name written in quotes
|
|
304
|
+
* (`(input)"Header in"`) ends at the closing quote. Throws the reason, in the file's words.
|
|
305
|
+
*/
|
|
306
|
+
function expandPath(path: string, combination: Record<string, string>, used: Set<string>): string {
|
|
307
|
+
let out = '';
|
|
308
|
+
let at = 0;
|
|
309
|
+
while (at < path.length) {
|
|
310
|
+
let end = path.indexOf('.', at);
|
|
311
|
+
if (end < 0) end = path.length;
|
|
312
|
+
if (path.startsWith(INPUT_SEGMENT, at)) {
|
|
313
|
+
let axis: string;
|
|
314
|
+
const start = at + INPUT_SEGMENT.length;
|
|
315
|
+
if (path[start] === '"') {
|
|
316
|
+
const close = path.indexOf('"', start + 1);
|
|
317
|
+
if (close < 0) throw new Error(`the quote after ${INPUT_SEGMENT} is never closed`);
|
|
318
|
+
axis = path.slice(start + 1, close);
|
|
319
|
+
end = close + 1;
|
|
320
|
+
if (end < path.length && path[end] !== '.')
|
|
321
|
+
throw new Error(`${INPUT_SEGMENT} takes a whole segment, and text follows the quoted axis name`);
|
|
322
|
+
} else {
|
|
323
|
+
axis = path.slice(start, end);
|
|
324
|
+
}
|
|
325
|
+
if (axis === '') throw new Error(`${INPUT_SEGMENT} needs the name of an axis`);
|
|
326
|
+
if (!Object.prototype.hasOwnProperty.call(combination, axis)) {
|
|
327
|
+
const known = Object.keys(combination);
|
|
328
|
+
throw new Error(
|
|
329
|
+
`the file has no VARIANT axis "${axis}" to take a segment from${known.length > 0 ? ` (its axes: ${known.join(', ')})` : ' (it has none)'}`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
const value = combination[axis];
|
|
333
|
+
// A name has no dot or brace, so such a value could only ever make a path of another shape than the file wrote.
|
|
334
|
+
if (/[.{}]/.test(value))
|
|
335
|
+
throw new Error(`the value "${value}" of the axis "${axis}" cannot be a path segment: a token's name holds no ".", "{" or "}"`);
|
|
336
|
+
used.add(axis);
|
|
337
|
+
out += value;
|
|
338
|
+
} else {
|
|
339
|
+
const segment = path.slice(at, end);
|
|
340
|
+
if (segment.includes(INPUT_SEGMENT))
|
|
341
|
+
throw new Error(`${INPUT_SEGMENT} takes a whole segment of the path, and the segment "${segment}" has text around it`);
|
|
342
|
+
out += segment;
|
|
343
|
+
}
|
|
344
|
+
at = end;
|
|
345
|
+
if (at < path.length) {
|
|
346
|
+
out += '.';
|
|
347
|
+
at += 1;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Replaces the axis segments in every reference of one member's tree, before anything checks it: the references
|
|
355
|
+
* that come out are ordinary, and every check on a reference applies to them. A tagged value (`(literal)…`) is
|
|
356
|
+
* left alone. The nodes it changes are copies; a tree with no segment is returned as it came.
|
|
357
|
+
*
|
|
358
|
+
* Each field that took a segment is noted on its node (`fromAxes`), so a token that is missing can be said to
|
|
359
|
+
* come from `hue=rose`. A reference that cannot be expanded is a problem at the line of its field, and is left as written.
|
|
360
|
+
*/
|
|
361
|
+
export function expandSegments(roots: OutNode[], combination: Record<string, string>): { roots: OutNode[]; problems: Problem[] } {
|
|
362
|
+
const problems: Problem[] = [];
|
|
363
|
+
|
|
364
|
+
const rewrite = (json: Json, used: Set<string>, fail: (message: string) => void): Json => {
|
|
365
|
+
if (typeof json === 'string') {
|
|
366
|
+
if (!json.includes(INPUT_SEGMENT)) return json;
|
|
367
|
+
// The references as the core's grammar reads them, each replaced where it is written.
|
|
368
|
+
let out = '';
|
|
369
|
+
let from = 0;
|
|
370
|
+
for (const { written } of referencesIn(json)) {
|
|
371
|
+
const at = json.indexOf(written, from);
|
|
372
|
+
out += json.slice(from, at);
|
|
373
|
+
from = at + written.length;
|
|
374
|
+
try {
|
|
375
|
+
out += written.includes(INPUT_SEGMENT) ? `{${expandPath(written.slice(1, -1), combination, used)}}` : written;
|
|
376
|
+
} catch (error) {
|
|
377
|
+
fail(`"${json}": ${(error as Error).message}`);
|
|
378
|
+
out += written;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return out + json.slice(from);
|
|
382
|
+
}
|
|
383
|
+
if (json === null || typeof json !== 'object') return json;
|
|
384
|
+
if (Array.isArray(json)) {
|
|
385
|
+
const next = json.map((item) => rewrite(item, used, fail));
|
|
386
|
+
return next.every((item, index) => item === json[index]) ? json : next;
|
|
387
|
+
}
|
|
388
|
+
// A tagged value is a literal on purpose, a property or a key: not a reference.
|
|
389
|
+
if ('tag' in json && 'value' in json && typeof json.tag === 'string') return json;
|
|
390
|
+
const record = json as Record<string, Json>;
|
|
391
|
+
const next: Record<string, Json> = {};
|
|
392
|
+
let changed = false;
|
|
393
|
+
for (const key of Object.keys(record)) {
|
|
394
|
+
next[key] = rewrite(record[key], used, fail);
|
|
395
|
+
if (next[key] !== record[key]) changed = true;
|
|
396
|
+
}
|
|
397
|
+
return changed ? next : json;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const visit = (node: OutNode): OutNode => {
|
|
401
|
+
if (node.kind === 'call') return node;
|
|
402
|
+
const fields: Record<string, Json> = {};
|
|
403
|
+
const fromAxes: Record<string, string[]> = {};
|
|
404
|
+
let changed = false;
|
|
405
|
+
for (const [name, json] of Object.entries(node.fields)) {
|
|
406
|
+
const used = new Set<string>();
|
|
407
|
+
const at = node.fieldLocs[name] ?? node.loc;
|
|
408
|
+
fields[name] = rewrite(json, used, (message) => problems.push({ severity: 'error', message, line: at.line, column: at.column }));
|
|
409
|
+
if (fields[name] !== json) changed = true;
|
|
410
|
+
if (used.size > 0) fromAxes[name] = [...used];
|
|
411
|
+
}
|
|
412
|
+
const children = node.children.map(visit);
|
|
413
|
+
if (!changed && children.every((child, index) => child === node.children[index])) return node;
|
|
414
|
+
const copy: Expanded = { ...node, fields, children };
|
|
415
|
+
if (Object.keys(fromAxes).length > 0) copy.fromAxes = fromAxes;
|
|
416
|
+
return copy;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
return { roots: roots.map(visit), problems };
|
|
420
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bindings inferred from a CSS-keyed group, laid over a member's evaluated layers.
|
|
3
|
+
*
|
|
4
|
+
* A layer that names a group (`tokens="{button}"`) gets the fields the group's keys imply, written
|
|
5
|
+
* as if the file had said them, so every existing check applies to them. What each key implies was
|
|
6
|
+
* worked out where the design system is (`groups.ts`, which holds the table) and travels on the
|
|
7
|
+
* component as a `GroupPlan`; this runs in both runtimes, since only an evaluated layer knows which
|
|
8
|
+
* fields a `when` already wrote, and those win. It is kept small on purpose: it is in every printed
|
|
9
|
+
* script, whether or not the component names a group. It holds no Figma API and nothing from
|
|
10
|
+
* `@toghen/lib`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { planKey } from './axes.js';
|
|
14
|
+
import type { Component, FieldValue, Laid, Layer, Loc, Value } from './model.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Infers the bindings of a member's layers and takes `tokens` off them: the engine is never
|
|
18
|
+
* given a field that is not Figma's.
|
|
19
|
+
*
|
|
20
|
+
* The root gets the component line's group; a root that names a group of its own takes that one instead.
|
|
21
|
+
*
|
|
22
|
+
* `selection` is the part keys the member selects on the axes left in the path: a group has a plan of its own for it.
|
|
23
|
+
*
|
|
24
|
+
* `seen` is told what each layer got (the reader uses it to say what was skipped; the engine passes none).
|
|
25
|
+
*
|
|
26
|
+
* Written plainly on purpose (no `?.` or `??`, which the ES2017 build makes longer): the plugin
|
|
27
|
+
* is built for ES2017.
|
|
28
|
+
*/
|
|
29
|
+
export function applyBindings(
|
|
30
|
+
tree: Layer,
|
|
31
|
+
component: Component,
|
|
32
|
+
seen?: (layer: Layer, group: string, laid: Laid) => void,
|
|
33
|
+
selection: Record<string, string> = {},
|
|
34
|
+
): void {
|
|
35
|
+
const groups = component.groups || {};
|
|
36
|
+
const visit = (layer: Layer, root: boolean): void => {
|
|
37
|
+
const fields = layer.fields;
|
|
38
|
+
const own = fields.tokens as { path: string } | undefined;
|
|
39
|
+
delete fields.tokens;
|
|
40
|
+
const group = (own ? own.path : root ? component.tokens : undefined) as string;
|
|
41
|
+
// A group taken from an axis passes that on to the fields it infers, so a missing token can be said to come from it.
|
|
42
|
+
const via = layer.fromAxes?.tokens;
|
|
43
|
+
const plan = groups[planKey(group, selection)] || groups[group];
|
|
44
|
+
if (plan) {
|
|
45
|
+
// A text has its own part; a frame has one, and a gap adds a second that depends on its layout, which only the evaluated layer knows.
|
|
46
|
+
const layout = fields.layoutMode ? ((fields.layoutMode as { value?: string }).value as 'NONE') : 'NONE';
|
|
47
|
+
const locs = layer.fieldLoc as Record<string, Loc>;
|
|
48
|
+
for (const laid of layer.kind === 'TEXT' ? [plan.TEXT] : [plan.box, plan[layout]]) {
|
|
49
|
+
if (!laid) continue;
|
|
50
|
+
// A field the layer has already, written on it or by a `when`, wins.
|
|
51
|
+
for (const field of Object.keys(laid.set)) {
|
|
52
|
+
if (field in fields) continue;
|
|
53
|
+
const token = { kind: 'token', path: laid.set[field] } as Value;
|
|
54
|
+
fields[field] = (field === 'fills' || field === 'strokes' ? [token] : token) as FieldValue;
|
|
55
|
+
locs[field] = locs.tokens || (layer.loc as Loc);
|
|
56
|
+
if (via) (layer.fromAxes as Record<string, string[]>)[field] = via;
|
|
57
|
+
}
|
|
58
|
+
if (seen) seen(layer, group, laid);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const child of layer.children) visit(child, false);
|
|
62
|
+
};
|
|
63
|
+
visit(tree, true);
|
|
64
|
+
}
|