@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,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The variables a token has left behind by changing collection.
|
|
3
|
+
*
|
|
4
|
+
* A token's home follows what the document says about it (`sum.ts`): the base collection, a modifier's, a folded
|
|
5
|
+
* axis's. When the document changes where a token lives (it starts to vary on a modifier, stops varying, moves between
|
|
6
|
+
* modifiers, or between the base and a shared axis collection), the variable the last sync wrote for it is in the old
|
|
7
|
+
* collection, and the sync would write a second one in the new. Figma's Plugin API has no move for a variable, so the
|
|
8
|
+
* old one is removed and a new one created: one step.
|
|
9
|
+
*
|
|
10
|
+
* What the sync wrote is what its stamps say it wrote: a variable stamped for the variable's key, in another collection
|
|
11
|
+
* than its home. A variable with no stamp of that key is somebody else's, and is never touched. Nor is one that is not
|
|
12
|
+
* in the collection the sync wrote it in (a person moved it): its receipt is keyed by the ids of the modes it was
|
|
13
|
+
* written in, and none of them is a mode of the collection it is in now. Nor is one in a collection nothing may be
|
|
14
|
+
* written to (`closedBecause`). Only a collection that is still one of the document's counts:
|
|
15
|
+
* a collection the document no longer names (a modifier that is gone, a base collection under another `--collection`
|
|
16
|
+
* name, a collection of a person's own) is left alone, as `variableSyncEngine` already says of it. The step is listed
|
|
17
|
+
* until it is taken: the old variable keeps its stamp until it is removed.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { FigmaCollectionIndex } from './figmaCollectionIndex.js';
|
|
21
|
+
import { closedBecause, type CollectionStanding } from './standing.js';
|
|
22
|
+
import type { Sum } from './sum.js';
|
|
23
|
+
|
|
24
|
+
/** A stamped variable that is in another collection than its token's home now. */
|
|
25
|
+
export interface Move {
|
|
26
|
+
/** The key of the sum variable it was written for. */
|
|
27
|
+
key: string;
|
|
28
|
+
/** The Figma id of the variable to remove. */
|
|
29
|
+
variableId: string;
|
|
30
|
+
/** The variable's name in the old collection. */
|
|
31
|
+
oldName: string;
|
|
32
|
+
/** The old collection. */
|
|
33
|
+
from: string;
|
|
34
|
+
/** The collection the token lives in now. */
|
|
35
|
+
to: string;
|
|
36
|
+
/** The name the token's variable has now. */
|
|
37
|
+
name: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Every variable each sum variable left behind, by its key: more than one where a token moved twice and neither old one was removed. */
|
|
41
|
+
export function movesOf(
|
|
42
|
+
sum: Sum,
|
|
43
|
+
standing: ReadonlyMap<string, CollectionStanding>,
|
|
44
|
+
figmaIndex: FigmaCollectionIndex,
|
|
45
|
+
): Map<string, Move[]> {
|
|
46
|
+
const moves = new Map<string, Move[]>();
|
|
47
|
+
for (const token of sum.tokens) {
|
|
48
|
+
if (token.routing.kind !== 'variable') continue;
|
|
49
|
+
for (const variable of token.variables) {
|
|
50
|
+
const home = standing.get(variable.collection);
|
|
51
|
+
if (!home || closedBecause(home)) continue;
|
|
52
|
+
for (const old of figmaIndex.variablesByToken[variable.key] ?? []) {
|
|
53
|
+
if (old.variableCollectionId === home.existing?.id) continue;
|
|
54
|
+
const left = figmaIndex.byId[old.variableCollectionId];
|
|
55
|
+
const leftStanding = left ? standing.get(left.name) : undefined;
|
|
56
|
+
if (!left || !leftStanding || leftStanding.existing?.id !== left.id || closedBecause(leftStanding)) continue;
|
|
57
|
+
const writtenHere = left.modes.some((mode) => old.stamp?.receipt[mode.modeId] !== undefined);
|
|
58
|
+
if (!writtenHere) continue;
|
|
59
|
+
moves.set(variable.key, [
|
|
60
|
+
...(moves.get(variable.key) ?? []),
|
|
61
|
+
{ key: variable.key, variableId: old.id, oldName: old.name, from: left.name, to: variable.collection, name: variable.name },
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return moves;
|
|
67
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { QueryScope } from '../../shared/protocol.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolves a query scope to the set of top-level nodes to walk.
|
|
5
|
+
*
|
|
6
|
+
* ### Why this exists
|
|
7
|
+
*
|
|
8
|
+
* Two call sites each carried their own copy of this, and both called
|
|
9
|
+
* `figma.loadPageAsync(page)` — a method the Plugin API has never had. The real
|
|
10
|
+
* form is `PageNode.loadAsync()`. Under `documentAccess: "dynamic-page"` (which
|
|
11
|
+
* this plugin's manifest declares) a page's `children` is not readable until it
|
|
12
|
+
* has been loaded, so every `scope: 'file'` operation threw before reaching any
|
|
13
|
+
* work. Five test files mocked the invented method, so the suite reported it as
|
|
14
|
+
* working.
|
|
15
|
+
*
|
|
16
|
+
* Keeping one implementation means the next correction lands once.
|
|
17
|
+
*
|
|
18
|
+
* @param scope - `'selection'`, `'page'`, or `'file'`
|
|
19
|
+
* @returns The root nodes for that scope
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* `findAllWithCriteria` over every page, page by page.
|
|
23
|
+
*
|
|
24
|
+
* Under `documentAccess: "dynamic-page"` the plugin may not call it on the
|
|
25
|
+
* document unless `figma.loadAllPagesAsync()` ran first (measured 2026-10-08, the
|
|
26
|
+
* first command-line run in a real file), and `use_figma` does not support
|
|
27
|
+
* `loadAllPagesAsync()`. On a loaded page the call is allowed in both, so the
|
|
28
|
+
* caller loads every page and this searches each.
|
|
29
|
+
*/
|
|
30
|
+
export function findInEveryPage<T extends NodeType[]>(criteria: FindAllCriteria<T>): Array<{ type: T[number] } & SceneNode> {
|
|
31
|
+
const found: Array<{ type: T[number] } & SceneNode> = [];
|
|
32
|
+
for (const page of figma.root.children) found.push(...page.findAllWithCriteria(criteria));
|
|
33
|
+
return found;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function resolveScopeRoots(scope: QueryScope): Promise<readonly SceneNode[]> {
|
|
37
|
+
switch (scope) {
|
|
38
|
+
case 'selection':
|
|
39
|
+
return figma.currentPage.selection;
|
|
40
|
+
|
|
41
|
+
case 'page':
|
|
42
|
+
return figma.currentPage.children;
|
|
43
|
+
|
|
44
|
+
case 'file': {
|
|
45
|
+
const roots: SceneNode[] = [];
|
|
46
|
+
for (const page of figma.root.children) {
|
|
47
|
+
// Required before reading `children` under dynamic-page access.
|
|
48
|
+
// Already-loaded pages resolve immediately, so this is safe to repeat.
|
|
49
|
+
await page.loadAsync();
|
|
50
|
+
roots.push(...page.children);
|
|
51
|
+
}
|
|
52
|
+
return roots;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
default:
|
|
56
|
+
return figma.currentPage.selection;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Operation } from '../../shared/operations.js';
|
|
2
|
+
import { isAlias } from './figmaTokenAdapter.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a plan's operations produce and depend on, in symbolic ids.
|
|
6
|
+
*
|
|
7
|
+
* A plan is a little dependency graph: `CREATE_COLLECTION` produces a symbol
|
|
8
|
+
* that `CREATE_VARIABLE` consumes, which produces one that `SET_VARIABLE_VALUE`
|
|
9
|
+
* consumes. `batchRunner` resolves those at execution time; this module is the
|
|
10
|
+
* same knowledge, factored out so the graph can also be *checked* before
|
|
11
|
+
* anything is written.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Symbolic ids start with `$`; everything else is a real Figma id. */
|
|
15
|
+
export const isSymbolic = (id: string): boolean => id.startsWith('$');
|
|
16
|
+
|
|
17
|
+
/** Every symbol a plan's own operations would create. */
|
|
18
|
+
export function producedSymbols(ops: readonly Operation[]): Set<string> {
|
|
19
|
+
const produced = new Set<string>();
|
|
20
|
+
|
|
21
|
+
for (const op of ops) {
|
|
22
|
+
switch (op.kind) {
|
|
23
|
+
case 'CREATE_COLLECTION':
|
|
24
|
+
if (op.symbolicId) produced.add(op.symbolicId);
|
|
25
|
+
for (const modeName of op.modes) {
|
|
26
|
+
produced.add(`$mode:${op.name}/${modeName}`);
|
|
27
|
+
}
|
|
28
|
+
break;
|
|
29
|
+
case 'ADD_MODE':
|
|
30
|
+
produced.add(`$mode:${op.collectionName}/${op.modeName}`);
|
|
31
|
+
break;
|
|
32
|
+
case 'CREATE_VARIABLE':
|
|
33
|
+
if (op.symbolicId) produced.add(op.symbolicId);
|
|
34
|
+
break;
|
|
35
|
+
default:
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return produced;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The ids an operation needs resolved before it can run. */
|
|
44
|
+
export function dependenciesOf(op: Operation): string[] {
|
|
45
|
+
switch (op.kind) {
|
|
46
|
+
case 'ADD_MODE':
|
|
47
|
+
case 'CREATE_VARIABLE':
|
|
48
|
+
return [op.collectionId];
|
|
49
|
+
case 'SET_VARIABLE_VALUE':
|
|
50
|
+
// An alias's target is a prerequisite too: a failed create blocks it.
|
|
51
|
+
return isAlias(op.value) ? [op.variableId, op.modeId, op.value.id] : [op.variableId, op.modeId];
|
|
52
|
+
case 'DELETE_VARIABLE':
|
|
53
|
+
return op.after ? [op.after] : [];
|
|
54
|
+
case 'BIND_VARIABLE':
|
|
55
|
+
case 'RENAME_VARIABLE':
|
|
56
|
+
case 'SET_CODE_SYNTAX':
|
|
57
|
+
case 'SET_VARIABLE_SCOPES':
|
|
58
|
+
return [op.variableId];
|
|
59
|
+
default:
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Warns about references no operation in the plan satisfies.
|
|
66
|
+
*
|
|
67
|
+
* This is the one thing the old dry-run mode could tell you that a plan could
|
|
68
|
+
* not — and it was always a property of the *plan*, not of a way of running it.
|
|
69
|
+
* Checking it here means it is reported when the plan is built, on the screen
|
|
70
|
+
* the user is already reading, rather than requiring a second command whose only
|
|
71
|
+
* job was to walk the same list without touching anything.
|
|
72
|
+
*
|
|
73
|
+
* A dangling symbol is a bug in an engine, not something a user can cause. It is
|
|
74
|
+
* reported rather than thrown because a plan that is 90% sound is still worth
|
|
75
|
+
* showing, with the broken part named.
|
|
76
|
+
*/
|
|
77
|
+
export function danglingSymbolWarnings(ops: readonly Operation[]): Operation[] {
|
|
78
|
+
const produced = producedSymbols(ops);
|
|
79
|
+
const seen = new Set<string>();
|
|
80
|
+
const warnings: Operation[] = [];
|
|
81
|
+
|
|
82
|
+
for (const op of ops) {
|
|
83
|
+
for (const dependency of dependenciesOf(op)) {
|
|
84
|
+
if (!isSymbolic(dependency) || produced.has(dependency) || seen.has(dependency)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
seen.add(dependency);
|
|
88
|
+
warnings.push({
|
|
89
|
+
kind: 'WARNING',
|
|
90
|
+
message: `This plan refers to "${dependency}", which nothing in it creates. Every operation depending on it will be skipped.`,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return warnings;
|
|
96
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a variable is for: the Figma fields it can fill (its scopes), and whether it is an opacity.
|
|
3
|
+
*
|
|
4
|
+
* A W3C token says neither. Its `$type` is `number` or `dimension`, and a dimension may be a gap, a radius or a
|
|
5
|
+
* width. So the purpose of a token is read from four sources, the first that answers winning
|
|
6
|
+
* (specs.md, *What a variable is for*):
|
|
7
|
+
*
|
|
8
|
+
* 1. **A declaration**, `$extensions["com.toghen"].figma.scopes`, on the token or the nearest group above it.
|
|
9
|
+
* 2. **The name**: the token's last key, through the table component files infer bindings with (`scopesOfKey`).
|
|
10
|
+
* 3. **References**: the tokens that refer to it, directly or through tokens with no purpose of their own, in any mode.
|
|
11
|
+
* 4. **The type**, for scope only. It never makes a token an opacity.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here touches Figma. The sum asks it once, after every token is built and folded, and each token keeps the
|
|
14
|
+
* answer (`SumToken.purpose`), so the plan, the comparison and the Inspector read one.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { referencePath, type Collection, type DesignSystem } from '@toghen/lib';
|
|
18
|
+
import { scopesOfKey } from '../../shared/components/groups.js';
|
|
19
|
+
import { flatMap } from './lists.js';
|
|
20
|
+
import type { Referrer } from './resolverModes.js';
|
|
21
|
+
import type { Purpose, SumToken } from './sum.js';
|
|
22
|
+
|
|
23
|
+
/** What each kind of Figma variable may be scoped to. `ALL_SCOPES` suits all. Measured: `COLOR_OPACITY` is accepted on a `FLOAT`. */
|
|
24
|
+
const SUITING: Partial<Record<VariableResolvedDataType, readonly VariableScope[]>> = {
|
|
25
|
+
COLOR: ['ALL_SCOPES', 'ALL_FILLS', 'FRAME_FILL', 'SHAPE_FILL', 'TEXT_FILL', 'STROKE_COLOR', 'EFFECT_COLOR'],
|
|
26
|
+
FLOAT: [
|
|
27
|
+
'ALL_SCOPES',
|
|
28
|
+
'CORNER_RADIUS',
|
|
29
|
+
'WIDTH_HEIGHT',
|
|
30
|
+
'GAP',
|
|
31
|
+
'STROKE_FLOAT',
|
|
32
|
+
'EFFECT_FLOAT',
|
|
33
|
+
'OPACITY',
|
|
34
|
+
'COLOR_OPACITY',
|
|
35
|
+
'FONT_WEIGHT',
|
|
36
|
+
'FONT_SIZE',
|
|
37
|
+
'LINE_HEIGHT',
|
|
38
|
+
'LETTER_SPACING',
|
|
39
|
+
'PARAGRAPH_SPACING',
|
|
40
|
+
'PARAGRAPH_INDENT',
|
|
41
|
+
],
|
|
42
|
+
STRING: ['ALL_SCOPES', 'TEXT_CONTENT', 'FONT_FAMILY', 'FONT_STYLE'],
|
|
43
|
+
BOOLEAN: ['ALL_SCOPES'],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const ALL_SCOPES: readonly VariableScope[] = ([] as VariableScope[]).concat(...(Object.values(SUITING) as VariableScope[][]));
|
|
47
|
+
const FILLS: readonly VariableScope[] = ['FRAME_FILL', 'SHAPE_FILL', 'TEXT_FILL'];
|
|
48
|
+
|
|
49
|
+
/** The scopes of a token no source reaches, by its `$type`. Anything not listed is offered to every field. */
|
|
50
|
+
const BY_TYPE: Record<string, readonly VariableScope[]> = {
|
|
51
|
+
color: ['ALL_FILLS', 'STROKE_COLOR', 'EFFECT_COLOR'],
|
|
52
|
+
dimension: ['GAP', 'WIDTH_HEIGHT', 'CORNER_RADIUS', 'STROKE_FLOAT'],
|
|
53
|
+
fontWeight: ['FONT_WEIGHT'],
|
|
54
|
+
fontFamily: ['FONT_FAMILY'],
|
|
55
|
+
letterSpacing: ['LETTER_SPACING'],
|
|
56
|
+
// A bare number is never offered to OPACITY or COLOR_OPACITY, where Figma would read 1 as 1%. Figma has no "all but" scope, so the rest are named.
|
|
57
|
+
number: [
|
|
58
|
+
'CORNER_RADIUS',
|
|
59
|
+
'WIDTH_HEIGHT',
|
|
60
|
+
'GAP',
|
|
61
|
+
'STROKE_FLOAT',
|
|
62
|
+
'EFFECT_FLOAT',
|
|
63
|
+
'FONT_WEIGHT',
|
|
64
|
+
'FONT_SIZE',
|
|
65
|
+
'LINE_HEIGHT',
|
|
66
|
+
'LETTER_SPACING',
|
|
67
|
+
'PARAGRAPH_SPACING',
|
|
68
|
+
'PARAGRAPH_INDENT',
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The scope a member of a composite fills, when the composite is one a reference gives a purpose through. */
|
|
73
|
+
const MEMBERS: Record<string, Record<string, VariableScope>> = {
|
|
74
|
+
typography: {
|
|
75
|
+
fontFamily: 'FONT_FAMILY',
|
|
76
|
+
fontWeight: 'FONT_WEIGHT',
|
|
77
|
+
fontSize: 'FONT_SIZE',
|
|
78
|
+
lineHeight: 'LINE_HEIGHT',
|
|
79
|
+
letterSpacing: 'LETTER_SPACING',
|
|
80
|
+
},
|
|
81
|
+
shadow: { color: 'EFFECT_COLOR', offsetX: 'EFFECT_FLOAT', offsetY: 'EFFECT_FLOAT', blur: 'EFFECT_FLOAT', spread: 'EFFECT_FLOAT' },
|
|
82
|
+
border: { color: 'STROKE_COLOR', width: 'STROKE_FLOAT' },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const suits = (type: VariableResolvedDataType, scope: VariableScope): boolean => (SUITING[type] ?? ['ALL_SCOPES']).includes(scope);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Figma refuses `ALL_FILLS` beside another fill scope and `ALL_SCOPES` beside anything, so a union is normalised:
|
|
89
|
+
* `ALL_SCOPES` absorbs the rest, `ALL_FILLS` absorbs the fill scopes. Only scopes the variable's type takes are kept.
|
|
90
|
+
*/
|
|
91
|
+
export function normalizeScopes(scopes: Iterable<VariableScope>, type: VariableResolvedDataType): VariableScope[] {
|
|
92
|
+
const kept = [...new Set(scopes)].filter((scope) => suits(type, scope));
|
|
93
|
+
if (kept.includes('ALL_SCOPES')) return ['ALL_SCOPES'];
|
|
94
|
+
const result = kept.includes('ALL_FILLS') ? kept.filter((scope) => !FILLS.includes(scope)) : kept;
|
|
95
|
+
return result.sort();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether two lists of scopes name the same scopes. Figma stores them in an order of its own, so they are compared as sets. */
|
|
99
|
+
export function sameScopes(a: readonly VariableScope[], b: readonly VariableScope[]): boolean {
|
|
100
|
+
const left = new Set(a);
|
|
101
|
+
const right = new Set(b);
|
|
102
|
+
return left.size === right.size && [...left].every((scope) => right.has(scope));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The reason a declared list is refused, in words, or the list when it is sound. */
|
|
106
|
+
export function checkDeclaration(list: unknown, type: VariableResolvedDataType): { scopes: VariableScope[] } | { problem: string } {
|
|
107
|
+
if (!Array.isArray(list) || list.some((entry) => typeof entry !== 'string')) {
|
|
108
|
+
return { problem: 'figma.scopes must be a list of Figma scope names' };
|
|
109
|
+
}
|
|
110
|
+
const scopes = [...new Set(list as string[])];
|
|
111
|
+
const unknown = scopes.filter((scope) => !ALL_SCOPES.includes(scope as VariableScope));
|
|
112
|
+
if (unknown.length > 0)
|
|
113
|
+
return {
|
|
114
|
+
problem: `${unknown.map((scope) => `"${scope}"`).join(', ')} ${unknown.length === 1 ? 'is not a scope' : 'are not scopes'} Figma has`,
|
|
115
|
+
};
|
|
116
|
+
if (scopes.includes('COLOR_OPACITY')) {
|
|
117
|
+
return { problem: "COLOR_OPACITY is not supported: the unit of a paint's opacity has not been measured, and Toghen never binds it" };
|
|
118
|
+
}
|
|
119
|
+
if (scopes.includes('ALL_SCOPES') && scopes.length > 1)
|
|
120
|
+
return { problem: 'ALL_SCOPES cannot be listed with other scopes, as Figma refuses it' };
|
|
121
|
+
const otherFills = scopes.filter((scope) => FILLS.includes(scope as VariableScope));
|
|
122
|
+
if (scopes.includes('ALL_FILLS') && otherFills.length > 0)
|
|
123
|
+
return { problem: `ALL_FILLS cannot be listed with ${otherFills.join(', ')}, as Figma refuses it` };
|
|
124
|
+
if (scopes.includes('OPACITY') && scopes.length > 1)
|
|
125
|
+
return { problem: 'OPACITY cannot be listed with other scopes: one number cannot be in two units' };
|
|
126
|
+
const misfit = scopes.filter((scope) => !suits(type, scope as VariableScope));
|
|
127
|
+
if (misfit.length > 0) return { problem: `${misfit.join(', ')} ${misfit.length === 1 ? 'does' : 'do'} not suit a ${type} variable` };
|
|
128
|
+
return { scopes: scopes as VariableScope[] };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** What a token's own definition says about its scopes: the list it holds, or the context that redefines it. */
|
|
132
|
+
export interface Declared {
|
|
133
|
+
scopes?: unknown;
|
|
134
|
+
/** `modifier=context`, where a context redefines the list. */
|
|
135
|
+
conflict?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The `figma.scopes` an `$extensions` object holds, when it holds any. */
|
|
139
|
+
export function scopesIn(extensions: unknown): unknown {
|
|
140
|
+
const figma = (extensions as { 'com.toghen'?: { figma?: unknown } } | null | undefined)?.['com.toghen']?.figma;
|
|
141
|
+
return typeof figma === 'object' && figma !== null ? (figma as { scopes?: unknown }).scopes : undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface PurposeInput {
|
|
145
|
+
collection: Collection;
|
|
146
|
+
ds: DesignSystem;
|
|
147
|
+
/** The Figma name of the base collection: a cell in it is not in any context. */
|
|
148
|
+
baseName: string;
|
|
149
|
+
/** The tokens that are variables, the folded ones as they now are. */
|
|
150
|
+
tokens: SumToken[];
|
|
151
|
+
/** The part tokens a fold took into another token's variable, by the token that stands for them. */
|
|
152
|
+
partsOf: ReadonlyMap<string, string>;
|
|
153
|
+
declared: ReadonlyMap<string, Declared>;
|
|
154
|
+
/** Who refers to each token (full lookup paths), in any mode Figma gets. */
|
|
155
|
+
referrers: ReadonlyMap<string, readonly Referrer[]>;
|
|
156
|
+
/** The `$type` a path has, when one. */
|
|
157
|
+
typeOf(path: string): string | undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** One thing that refers to a token and what it gives it: scopes, and whether they are an opacity's. */
|
|
161
|
+
interface Gift {
|
|
162
|
+
scopes: VariableScope[];
|
|
163
|
+
opacity: boolean;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Gives every variable token its purpose, and refuses the ones whose declaration or opacity cannot be written.
|
|
168
|
+
* Returns the warnings that are not a token's refusal.
|
|
169
|
+
*/
|
|
170
|
+
export function assignPurposes(input: PurposeInput): string[] {
|
|
171
|
+
const { collection, referrers } = input;
|
|
172
|
+
const warnings: string[] = [];
|
|
173
|
+
const byPath = new Map(input.tokens.map((token) => [token.path, token]));
|
|
174
|
+
const typeOfVariable = (token: SumToken): VariableResolvedDataType =>
|
|
175
|
+
(token.routing as { variableType: VariableResolvedDataType }).variableType;
|
|
176
|
+
|
|
177
|
+
// Where a group holds a list: the nearest one, from the token's group up to the root.
|
|
178
|
+
const holders = new Map<string, { group: string; scopes: unknown } | null>();
|
|
179
|
+
const nearestGroup = (refPath: string): { group: string; scopes: unknown } | null => {
|
|
180
|
+
const segments = refPath.split('.').slice(0, -1);
|
|
181
|
+
for (let depth = segments.length; depth >= 0; depth--) {
|
|
182
|
+
const group = segments.slice(0, depth).join('.');
|
|
183
|
+
let found = holders.get(group);
|
|
184
|
+
if (found === undefined) {
|
|
185
|
+
const scopes = scopesIn(collection.getGroupAttribute(group, '$extensions'));
|
|
186
|
+
found = scopes === undefined ? null : { group, scopes };
|
|
187
|
+
holders.set(group, found);
|
|
188
|
+
}
|
|
189
|
+
if (found) return found;
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const refusals = new Map<string, string>();
|
|
195
|
+
|
|
196
|
+
/** The purpose a token has of its own, from a declaration or else from its name; `null` when it has neither. */
|
|
197
|
+
// `hidden`: the list is `[]`. That says only that the variable is hidden: whether it is an opacity comes from its name,
|
|
198
|
+
// and failing that from the tokens that refer to it, as if it declared nothing (`named`: its name answered).
|
|
199
|
+
type Own = Gift & { source: 'declaration' | 'name'; hidden?: true; named?: boolean };
|
|
200
|
+
const own = new Map<string, Own | null>();
|
|
201
|
+
const ownOf = (token: SumToken): Own | null => {
|
|
202
|
+
if (own.has(token.path)) return own.get(token.path) as Own | null;
|
|
203
|
+
const type = typeOfVariable(token);
|
|
204
|
+
const refPath = referencePath(token.path);
|
|
205
|
+
const declaration = input.declared.get(refPath);
|
|
206
|
+
const holder = declaration?.scopes !== undefined ? { group: undefined, scopes: declaration.scopes } : nearestGroup(refPath);
|
|
207
|
+
|
|
208
|
+
let result: Own | null = null;
|
|
209
|
+
if (declaration?.conflict !== undefined) {
|
|
210
|
+
refusals.set(
|
|
211
|
+
token.path,
|
|
212
|
+
`"${token.path}" redefines figma.scopes in ${declaration.conflict}, and a variable has one list for all its modes; skipped.`,
|
|
213
|
+
);
|
|
214
|
+
result = { scopes: [], opacity: false, source: 'declaration' };
|
|
215
|
+
} else if (holder) {
|
|
216
|
+
const checked = checkDeclaration(holder.scopes, type);
|
|
217
|
+
if ('problem' in checked) {
|
|
218
|
+
const where =
|
|
219
|
+
holder.group === undefined ? '' : ` (declared on ${holder.group === '' ? 'the document root' : `the group "${holder.group}"`})`;
|
|
220
|
+
refusals.set(
|
|
221
|
+
token.path,
|
|
222
|
+
`"${token.path}" has figma.scopes ${JSON.stringify(holder.scopes)}${where}, but ${checked.problem}; skipped.`,
|
|
223
|
+
);
|
|
224
|
+
result = { scopes: [], opacity: false, source: 'declaration' };
|
|
225
|
+
} else if (checked.scopes.length === 0) {
|
|
226
|
+
const named = scopesOfKey(refPath.slice(refPath.lastIndexOf('.') + 1))?.filter((scope) => suits(type, scope)) ?? [];
|
|
227
|
+
result = { scopes: [], opacity: named.includes('OPACITY'), source: 'declaration', hidden: true, named: named.length > 0 };
|
|
228
|
+
} else {
|
|
229
|
+
result = { scopes: checked.scopes, opacity: checked.scopes.includes('OPACITY'), source: 'declaration' };
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
const named = scopesOfKey(refPath.slice(refPath.lastIndexOf('.') + 1))?.filter((scope) => suits(type, scope));
|
|
233
|
+
if (named && named.length > 0) result = { scopes: named, opacity: named.includes('OPACITY'), source: 'name' };
|
|
234
|
+
}
|
|
235
|
+
own.set(token.path, result);
|
|
236
|
+
return result;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// A part is judged by the token that stands for it, whose variable it is a cell of.
|
|
240
|
+
const gifts = new Map<string, Gift | null>();
|
|
241
|
+
const flowing = new Set<string>();
|
|
242
|
+
const ownOfAny = (path: string): { token: SumToken; own: Own | null } | undefined => {
|
|
243
|
+
const token = byPath.get(input.partsOf.get(path) ?? path);
|
|
244
|
+
return token && token.routing.kind === 'variable' ? { token, own: ownOf(token) } : undefined;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/** Whether a token is an opacity: its own say, and for a hidden one that its name leaves open, the tokens that refer to it. */
|
|
248
|
+
const opacityOf = (token: SumToken, mine: Own): boolean =>
|
|
249
|
+
mine.hidden && !mine.named ? flowedInto(token.path)?.opacity === true && suits(typeOfVariable(token), 'OPACITY') : mine.opacity;
|
|
250
|
+
|
|
251
|
+
/** What the tokens that refer to `path` give it, reached through tokens with no purpose of their own. `null` when nothing does. */
|
|
252
|
+
const flowedInto = (path: string): Gift | null => {
|
|
253
|
+
if (gifts.has(path)) return gifts.get(path) as Gift | null;
|
|
254
|
+
if (flowing.has(path)) return null;
|
|
255
|
+
flowing.add(path);
|
|
256
|
+
const given: Gift[] = [];
|
|
257
|
+
for (const { from, at } of referrers.get(path) ?? []) {
|
|
258
|
+
const fromType = input.typeOf(from);
|
|
259
|
+
const member = MEMBERS[fromType ?? ''];
|
|
260
|
+
if (member) {
|
|
261
|
+
// A composite: the member's field.
|
|
262
|
+
const key = at.split('.').find((segment) => segment !== '' && !/^\d+$/.test(segment));
|
|
263
|
+
const scope = key === undefined ? undefined : member[key];
|
|
264
|
+
if (scope) given.push({ scopes: [scope], opacity: false });
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (at !== '') continue;
|
|
268
|
+
const referrer = ownOfAny(from);
|
|
269
|
+
if (referrer === undefined) continue;
|
|
270
|
+
// A hidden token gives nothing to the scopes of what it refers to, but passes an opacity on as any token does.
|
|
271
|
+
const gift = referrer.own?.hidden
|
|
272
|
+
? { scopes: [], opacity: opacityOf(referrer.token, referrer.own) }
|
|
273
|
+
: (referrer.own ?? flowedInto(from));
|
|
274
|
+
if (gift && (gift.scopes.length > 0 || gift.opacity)) given.push(gift);
|
|
275
|
+
}
|
|
276
|
+
flowing.delete(path);
|
|
277
|
+
const opacities = given.filter((gift) => gift.opacity);
|
|
278
|
+
const plain = given.filter((gift) => !gift.opacity);
|
|
279
|
+
// Two units meet: the token stays plain, and holds what the others want.
|
|
280
|
+
const result: Gift | null =
|
|
281
|
+
given.length === 0
|
|
282
|
+
? null
|
|
283
|
+
: opacities.length > 0 && plain.length === 0
|
|
284
|
+
? { scopes: ['OPACITY'], opacity: true }
|
|
285
|
+
: { scopes: flatMap(plain, (gift) => gift.scopes), opacity: false };
|
|
286
|
+
gifts.set(path, result);
|
|
287
|
+
return result;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
for (const token of input.tokens) {
|
|
291
|
+
if (token.routing.kind !== 'variable') continue;
|
|
292
|
+
const type = typeOfVariable(token);
|
|
293
|
+
const mine = ownOf(token);
|
|
294
|
+
let purpose: Purpose;
|
|
295
|
+
if (mine) {
|
|
296
|
+
purpose = { scopes: mine.scopes, opacity: opacityOf(token, mine), source: mine.source };
|
|
297
|
+
} else {
|
|
298
|
+
const flowed = flowedInto(token.path);
|
|
299
|
+
const scopes = flowed ? normalizeScopes(flowed.scopes, type) : [];
|
|
300
|
+
if (flowed && (flowed.opacity ? suits(type, 'OPACITY') : scopes.length > 0)) {
|
|
301
|
+
purpose = { scopes: flowed.opacity ? ['OPACITY'] : scopes, opacity: flowed.opacity && suits(type, 'OPACITY'), source: 'reference' };
|
|
302
|
+
} else {
|
|
303
|
+
const listed = BY_TYPE[token.type ?? ''];
|
|
304
|
+
purpose = { scopes: listed ? [...listed] : ['ALL_SCOPES'], opacity: false, source: 'type' };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
token.purpose = purpose;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// The tokens a part stands in for: a part that declares a list of its own is ignored.
|
|
311
|
+
for (const [part, rest] of input.partsOf) {
|
|
312
|
+
if (input.declared.get(referencePath(part))?.scopes !== undefined) {
|
|
313
|
+
warnings.push(
|
|
314
|
+
`"${part}" declares figma.scopes, but it is folded into "${rest}", whose variable is judged on its rest token; the list is ignored.`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// An opacity is a number from 0 to 1 in every cell, or it is refused: a wrong unit draws a layer at 1%.
|
|
320
|
+
for (const token of input.tokens) {
|
|
321
|
+
if (token.routing.kind !== 'variable' || !token.purpose?.opacity || refusals.has(token.path)) continue;
|
|
322
|
+
const bad = outsideUnit(token, input.baseName);
|
|
323
|
+
if (bad)
|
|
324
|
+
refusals.set(
|
|
325
|
+
token.path,
|
|
326
|
+
`"${token.path}" is an opacity, a number from 0 to 1, and is ${bad.value}${bad.where ? ` in ${bad.where}` : ''}; skipped.`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
for (const [path, reason] of refusals) {
|
|
331
|
+
const token = byPath.get(path) as SumToken;
|
|
332
|
+
token.routing = { kind: 'refused', reason, skip: true };
|
|
333
|
+
token.variables = [];
|
|
334
|
+
}
|
|
335
|
+
return warnings;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The first cell of an opacity that is not a number from 0 to 1: the value as written, and the context it is in. */
|
|
339
|
+
function outsideUnit(token: SumToken, baseName: string): { value: string; where: string } | undefined {
|
|
340
|
+
for (const variable of token.variables) {
|
|
341
|
+
for (const [mode, cell] of variable.cells) {
|
|
342
|
+
if (cell.kind === 'link') continue;
|
|
343
|
+
const value = cell.value;
|
|
344
|
+
// A reference whose value cannot be read has nothing to check: it is reported where it is written.
|
|
345
|
+
if (value === undefined) continue;
|
|
346
|
+
if (typeof value === 'number' && value >= 0 && value <= 1) continue;
|
|
347
|
+
return { value: JSON.stringify(value), where: variable.collection === baseName ? '' : `${variable.collection}=${mode}` };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|