@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,255 @@
|
|
|
1
|
+
import { normalizeColorToSRGB01, normalizeDimensionToPx, fontFamilyNames, type ColorValue, type DimensionValue } from '@toghen/lib';
|
|
2
|
+
|
|
3
|
+
const EPSILON = 1e-9;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The spec models fontFamily as an ordered fallback stack, and `@toghen/lib`
|
|
7
|
+
* preserves it as an array — §9.1 forbids collapsing it during resolution.
|
|
8
|
+
* This is the stack as a list of names, however it was written.
|
|
9
|
+
*/
|
|
10
|
+
export function fontStack(fontFamily: unknown): string[] {
|
|
11
|
+
if (Array.isArray(fontFamily) || (typeof fontFamily === 'string' && fontFamily.trim())) {
|
|
12
|
+
return fontFamilyNames(fontFamily).map(String);
|
|
13
|
+
}
|
|
14
|
+
return [String(fontFamily ?? '')];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Figma names exactly one family, in a STRING variable: the stack's first.
|
|
19
|
+
* A text style may take a later one (`utils/fontStack.ts`), but the variable
|
|
20
|
+
* keeps the token's meaning and does not depend on the machine that ran the sync.
|
|
21
|
+
*/
|
|
22
|
+
export function toFigmaFontFamily(fontFamily: unknown): string {
|
|
23
|
+
return fontStack(fontFamily)[0] ?? '';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function clamp01(value: number): number {
|
|
27
|
+
if (Number.isNaN(value)) return 0;
|
|
28
|
+
return Math.min(1, Math.max(0, value));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toHexByte(value: number): string {
|
|
32
|
+
return Math.round(clamp01(value) * 255)
|
|
33
|
+
.toString(16)
|
|
34
|
+
.padStart(2, '0');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Converts a token colour to Figma's `RGBA`.
|
|
39
|
+
*
|
|
40
|
+
* Figma's `RGB`/`RGBA` channels are **sRGB** 0–1 — the same encoding the colour
|
|
41
|
+
* picker shows and the same one `normalizeColorToSRGB01()` produces. So this is
|
|
42
|
+
* a pass-through, and that is the whole point: this function used to apply the
|
|
43
|
+
* sRGB→linear transfer curve on top, which shipped every mid-tone several stops
|
|
44
|
+
* too dark (`#808080` became `#373737`).
|
|
45
|
+
*
|
|
46
|
+
* It also disagreed with `figmaStyleAdapter`, which has always written shadow
|
|
47
|
+
* and gradient colours as plain sRGB — so one token value rendered two
|
|
48
|
+
* different ways in the same file depending on where it landed.
|
|
49
|
+
*
|
|
50
|
+
* `clamp01` stays: culori can return slightly out-of-gamut channels when
|
|
51
|
+
* converting from wider spaces such as `lab` or `oklch`, and Figma rejects
|
|
52
|
+
* values outside 0–1.
|
|
53
|
+
*
|
|
54
|
+
* ### Known limitation: Display P3 documents
|
|
55
|
+
*
|
|
56
|
+
* Figma's channels are gamma-encoded **in the document's colour profile**, not
|
|
57
|
+
* unconditionally sRGB: *"All colors are specified in the same color space
|
|
58
|
+
* which will be the color profile of the document, `documentColorProfile`."*
|
|
59
|
+
* In a default (sRGB) document these are ordinary sRGB values and this
|
|
60
|
+
* pass-through is exact. In a **Display P3** document the same numbers are read
|
|
61
|
+
* as P3 coordinates, so an sRGB token would land slightly more saturated.
|
|
62
|
+
*
|
|
63
|
+
* Handling that means reading `figma.root.documentColorProfile` and converting
|
|
64
|
+
* sRGB→P3 when it is `DISPLAY_P3`. Not done here: it needs its own decision
|
|
65
|
+
* about which profile a token document is authored in, and the error is far
|
|
66
|
+
* smaller than the sRGB→linear conversion this replaced.
|
|
67
|
+
*
|
|
68
|
+
* Since Update 141 a paint, a gradient stop and an effect colour may carry a
|
|
69
|
+
* `sourceColor` (sRGB, Display P3, OKLCH and others) beside `r`/`g`/`b`, which
|
|
70
|
+
* stay the profile's projection. That could carry a token's `colorSpace` as
|
|
71
|
+
* authored, for paints and effects; Figma documents nothing of the kind for
|
|
72
|
+
* variables. Not used here.
|
|
73
|
+
*/
|
|
74
|
+
function toFigmaRgba(value: string | ColorValue): RGBA {
|
|
75
|
+
const srgb = normalizeColorToSRGB01(value);
|
|
76
|
+
return {
|
|
77
|
+
r: clamp01(srgb.r),
|
|
78
|
+
g: clamp01(srgb.g),
|
|
79
|
+
b: clamp01(srgb.b),
|
|
80
|
+
a: clamp01(srgb.a),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* What a number is in Figma: `percent` for an opacity, which a variable bound to a layer's opacity holds as a
|
|
86
|
+
* percentage (`55` is 55%, drawn at 0.55), and nothing for every other number.
|
|
87
|
+
*/
|
|
88
|
+
export type FigmaUnit = 'percent';
|
|
89
|
+
|
|
90
|
+
/** An opacity from 0 to 1, as the percentage a Figma variable holds. Rounded to what a float can say, so 0.55 is 55 and not 55.00000000000001. */
|
|
91
|
+
const toPercent = (fraction: number): number => Number((fraction * 100).toPrecision(12));
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A token value in Figma's representation. `unit` says the number is an opacity: it is written × 100.
|
|
95
|
+
* Both sides of a comparison (`figmaValuesEqual`) are in this representation, so they compare in percent.
|
|
96
|
+
*/
|
|
97
|
+
export function toFigmaValue(value: unknown, type: string, unit?: FigmaUnit): VariableValue {
|
|
98
|
+
switch (type) {
|
|
99
|
+
case 'color':
|
|
100
|
+
if (
|
|
101
|
+
typeof value !== 'string' &&
|
|
102
|
+
(typeof value !== 'object' || value === null || !('colorSpace' in value) || !('components' in value))
|
|
103
|
+
) {
|
|
104
|
+
throw new Error(`Unsupported color value format: ${JSON.stringify(value)}`);
|
|
105
|
+
}
|
|
106
|
+
return toFigmaRgba(value as string | ColorValue);
|
|
107
|
+
|
|
108
|
+
case 'dimension':
|
|
109
|
+
// The library does the unit arithmetic, and throws on a non-dimension.
|
|
110
|
+
return normalizeDimensionToPx(value as DimensionValue);
|
|
111
|
+
|
|
112
|
+
case 'letterSpacing':
|
|
113
|
+
// Figma binds a number to letter spacing as pixels. `em` has none: sum.ts reports it before it gets here.
|
|
114
|
+
if (typeof value === 'object' && value !== null && (value as { unit?: unknown }).unit === 'em') {
|
|
115
|
+
throw new Error('A letter spacing in em has no pixel value without a font size.');
|
|
116
|
+
}
|
|
117
|
+
return normalizeDimensionToPx(value as DimensionValue);
|
|
118
|
+
|
|
119
|
+
case 'number':
|
|
120
|
+
case 'fontWeight': {
|
|
121
|
+
const numeric = Number(value);
|
|
122
|
+
if (!Number.isFinite(numeric)) {
|
|
123
|
+
throw new Error(`Cannot convert value "${String(value)}" to number.`);
|
|
124
|
+
}
|
|
125
|
+
return unit === 'percent' && type === 'number' ? toPercent(numeric) : numeric;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
case 'string':
|
|
129
|
+
return String(value);
|
|
130
|
+
|
|
131
|
+
case 'fontFamily':
|
|
132
|
+
return toFigmaFontFamily(value);
|
|
133
|
+
|
|
134
|
+
case 'boolean':
|
|
135
|
+
return Boolean(value);
|
|
136
|
+
|
|
137
|
+
default:
|
|
138
|
+
throw new Error(`Cannot convert type "${type}" to a Figma Variable value.`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Relative tolerance for comparing two numbers Figma has stored.
|
|
144
|
+
*
|
|
145
|
+
* **Figma stores float32.** Measured: `0.4` reads back as `0.4000000059604645`
|
|
146
|
+
* and a PERCENT line height of `160` as `160.0000023841858` — see
|
|
147
|
+
* `.reference/figma/measured_behaviour.md`. So a value written and read back is
|
|
148
|
+
* never bit-identical to the one sent, and comparing with `===` would report
|
|
149
|
+
* every token in the file as changed.
|
|
150
|
+
*
|
|
151
|
+
* The tolerance is **relative** because the error is. float32 carries a 24-bit
|
|
152
|
+
* mantissa, so its relative epsilon is ~1.19e-7; an absolute tolerance tuned for
|
|
153
|
+
* a colour channel in 0–1 would be far too tight for a `9999px` radius.
|
|
154
|
+
*/
|
|
155
|
+
const FLOAT32_RELATIVE_TOLERANCE = 1e-6;
|
|
156
|
+
/** Floor for values at or near zero, where a relative tolerance vanishes. */
|
|
157
|
+
const NEAR_ZERO_TOLERANCE = 1e-9;
|
|
158
|
+
|
|
159
|
+
export function numbersEqual(a: number, b: number): boolean {
|
|
160
|
+
if (a === b) return true;
|
|
161
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) return false;
|
|
162
|
+
|
|
163
|
+
const scale = Math.max(Math.abs(a), Math.abs(b));
|
|
164
|
+
return Math.abs(a - b) <= Math.max(NEAR_ZERO_TOLERANCE, FLOAT32_RELATIVE_TOLERANCE * scale);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isRgbLike(value: unknown): value is RGBA {
|
|
168
|
+
return (
|
|
169
|
+
typeof value === 'object' &&
|
|
170
|
+
value !== null &&
|
|
171
|
+
typeof (value as RGB).r === 'number' &&
|
|
172
|
+
typeof (value as RGB).g === 'number' &&
|
|
173
|
+
typeof (value as RGB).b === 'number'
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function isAlias(value: unknown): value is VariableAlias {
|
|
178
|
+
return typeof value === 'object' && value !== null && (value as VariableAlias).type === 'VARIABLE_ALIAS';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Whether two Figma variable values are the same value.
|
|
183
|
+
*
|
|
184
|
+
* Both sides must already be in **Figma's** representation. Do not compare by
|
|
185
|
+
* round-tripping through `fromFigmaValue()`: that is lossy by design — a
|
|
186
|
+
* `{colorSpace, components}` token becomes a hex string on the way back — so a
|
|
187
|
+
* comparison made there would report differences that do not exist.
|
|
188
|
+
*
|
|
189
|
+
* An alias never equals a concrete value. A designer pointing a variable at
|
|
190
|
+
* another variable really has changed it, and saying so is the honest answer.
|
|
191
|
+
*/
|
|
192
|
+
export function figmaValuesEqual(a: VariableValue | undefined, b: VariableValue | undefined): boolean {
|
|
193
|
+
if (a === undefined || b === undefined) return a === b;
|
|
194
|
+
|
|
195
|
+
if (isAlias(a) || isAlias(b)) {
|
|
196
|
+
return isAlias(a) && isAlias(b) && a.id === b.id;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (typeof a === 'number' && typeof b === 'number') {
|
|
200
|
+
return numbersEqual(a, b);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (isRgbLike(a) && isRgbLike(b)) {
|
|
204
|
+
// Alpha is optional on RGB; Figma treats an absent alpha as opaque.
|
|
205
|
+
return numbersEqual(a.r, b.r) && numbersEqual(a.g, b.g) && numbersEqual(a.b, b.b) && numbersEqual(a.a ?? 1, b.a ?? 1);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return a === b;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Turns a value Figma holds back into something readable.
|
|
213
|
+
*
|
|
214
|
+
* **The alias case is checked before the type**, and that ordering is the whole
|
|
215
|
+
* of it: a `VariableAlias` is an object with no `r`/`g`/`b`, so a COLOR variable
|
|
216
|
+
* pointed at another variable would fall through `clamp01(undefined ?? 0)` and
|
|
217
|
+
* read back as a confident `#000000`. A silently wrong value is worse than an
|
|
218
|
+
* unreadable one — this is the only place in the plugin where a Figma value is
|
|
219
|
+
* shown to a person rather than compared, so it is the only place that mistake
|
|
220
|
+
* would be believed.
|
|
221
|
+
*
|
|
222
|
+
* The alias's target name is not knowable here. `resolveAliasName` is how the
|
|
223
|
+
* caller — which holds the variable index — supplies it; without one the id is
|
|
224
|
+
* shown, which is at least true.
|
|
225
|
+
*/
|
|
226
|
+
export function fromFigmaValue(
|
|
227
|
+
value: VariableValue,
|
|
228
|
+
type: VariableResolvedDataType,
|
|
229
|
+
resolveAliasName?: (variableId: string) => string | undefined,
|
|
230
|
+
): unknown {
|
|
231
|
+
if (isAlias(value)) {
|
|
232
|
+
return `→ ${resolveAliasName?.(value.id) ?? value.id}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
switch (type) {
|
|
236
|
+
case 'COLOR': {
|
|
237
|
+
const color = value as RGBA;
|
|
238
|
+
// Figma channels are already sRGB — see toFigmaRgba().
|
|
239
|
+
const r = clamp01(color.r ?? 0);
|
|
240
|
+
const g = clamp01(color.g ?? 0);
|
|
241
|
+
const b = clamp01(color.b ?? 0);
|
|
242
|
+
const a = clamp01(color.a ?? 1);
|
|
243
|
+
|
|
244
|
+
const hex = `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`;
|
|
245
|
+
const alphaHex = toHexByte(a);
|
|
246
|
+
return Math.abs(a - 1) < EPSILON ? hex : `${hex}${alphaHex}`;
|
|
247
|
+
}
|
|
248
|
+
case 'FLOAT':
|
|
249
|
+
case 'STRING':
|
|
250
|
+
case 'BOOLEAN':
|
|
251
|
+
return value;
|
|
252
|
+
default:
|
|
253
|
+
return value;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { DesignSystem, DesignToken } from '@toghen/lib';
|
|
2
|
+
|
|
3
|
+
export interface FlattenedTokenEntry {
|
|
4
|
+
path: string;
|
|
5
|
+
token: DesignToken;
|
|
6
|
+
collection: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Yields every token of a `DesignSystem`, resolved.
|
|
11
|
+
*
|
|
12
|
+
* A system may hold a multi-mode collection alongside a single-mode one, and a
|
|
13
|
+
* mode that only some collections know about skips the others rather than
|
|
14
|
+
* aborting the walk (`getAllPaths()` throws `InvalidModeError` for a mode its
|
|
15
|
+
* collection does not declare).
|
|
16
|
+
*
|
|
17
|
+
* @param ds - The system to walk
|
|
18
|
+
* @param mode - Optional mode; collections lacking it are skipped. When omitted,
|
|
19
|
+
* each collection's own default mode is used.
|
|
20
|
+
*/
|
|
21
|
+
export function* flattenDesignSystem(ds: DesignSystem, mode?: string): Generator<FlattenedTokenEntry> {
|
|
22
|
+
for (const collectionName of ds.getCollectionNames()) {
|
|
23
|
+
const collection = ds.getCollection(collectionName);
|
|
24
|
+
if (!collection) continue;
|
|
25
|
+
|
|
26
|
+
if (mode !== undefined && !collection.getModes().includes(mode)) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const tokenPath of collection.getAllPaths(mode)) {
|
|
31
|
+
const path = `${collectionName}.${tokenPath}`;
|
|
32
|
+
const token = ds.resolveToken(path, mode);
|
|
33
|
+
if (!token) continue;
|
|
34
|
+
|
|
35
|
+
yield { path, token, collection: collectionName };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parts folded into modes, or left as one variable each.
|
|
3
|
+
*
|
|
4
|
+
* A subgroup whose key is in the parts vocabulary is a part (lib's `partsOf`). A component is the group
|
|
5
|
+
* above the first part of a path, and it varies along the vocabulary's axes. Each axis, for each component, is
|
|
6
|
+
* either folded or not:
|
|
7
|
+
*
|
|
8
|
+
* - **Folded:** a collection named after the axis, `rest` first and a mode per value. The variable of the
|
|
9
|
+
* rest token (the path with the part segment removed) takes its cell in a mode from the part token.
|
|
10
|
+
* - **Not folded:** the segment stays in the path, and the part token is a variable of its own.
|
|
11
|
+
*
|
|
12
|
+
* The decision is per axis per component, in the vocabulary's order, and the reason an axis did not fold is kept
|
|
13
|
+
* to be said. This file only decides and re-homes; where a token lives otherwise is `sum.ts`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { cssLinkName, cssPropertyName, referencePath, type Collection, type Part, type Resolver } from '@toghen/lib';
|
|
17
|
+
import { REST } from '../../shared/components/axes.js';
|
|
18
|
+
import { flatMap } from './lists.js';
|
|
19
|
+
import { MAX_MODES } from './resolverModes.js';
|
|
20
|
+
import { linkVariableName } from './tokenPath.js';
|
|
21
|
+
import type { SumCell, SumCollection, SumToken, SumVariable } from './sum.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The component groups a loaded document lays out as one variable per part (`layout=parts` in a component file),
|
|
25
|
+
* as the command line told the plugin when it checked the files. Every plan and every reader of the sum asks it,
|
|
26
|
+
* so they agree on where a variable is.
|
|
27
|
+
*/
|
|
28
|
+
const unfolded = new WeakMap<Resolver, readonly string[]>();
|
|
29
|
+
export const unfoldedFor = (resolver: Resolver): readonly string[] => unfolded.get(resolver) ?? [];
|
|
30
|
+
export const setUnfolded = (resolver: Resolver, groups: readonly string[]): void => void unfolded.set(resolver, groups);
|
|
31
|
+
|
|
32
|
+
export interface AxisFold {
|
|
33
|
+
axis: string;
|
|
34
|
+
/** The part keys the component uses on this axis, in the vocabulary's order. */
|
|
35
|
+
values: string[];
|
|
36
|
+
folded: boolean;
|
|
37
|
+
/** Why it did not fold, in words. */
|
|
38
|
+
why?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ComponentFold {
|
|
42
|
+
/** The component's group: the path above the first part. */
|
|
43
|
+
group: string;
|
|
44
|
+
/** Every axis its parts use, in the vocabulary's order. */
|
|
45
|
+
axes: AxisFold[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Folds {
|
|
49
|
+
components: ComponentFold[];
|
|
50
|
+
/** Folded axis to the modes of its collection, `rest` first, shared by every component that folds it. */
|
|
51
|
+
collections: Map<string, string[]>;
|
|
52
|
+
/** Rest path to folded axis (in vocabulary order) to mode to the path of the part token that gives it. */
|
|
53
|
+
parts: Map<string, Map<string, Map<string, string>>>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Decides, for every component the paths hold, which axes fold. `unfold` names the groups laid out as
|
|
58
|
+
* one variable per part whatever the tokens allow (`layout=parts`). Throws what lib throws for a bad vocabulary
|
|
59
|
+
* or two parts of one axis on a path.
|
|
60
|
+
*/
|
|
61
|
+
export function foldsOf(collection: Collection, paths: readonly string[], unfold: readonly string[] = []): Folds {
|
|
62
|
+
// Each part token by its component (lib's `partsOf`), with the parts it passes through and their twins.
|
|
63
|
+
const byGroup = new Map<string, { path: string; parts: Part[] }[]>();
|
|
64
|
+
for (const path of paths) {
|
|
65
|
+
const { parts, component } = collection.partsOf(path);
|
|
66
|
+
if (component === null) continue;
|
|
67
|
+
(byGroup.get(component) ?? byGroup.set(component, []).get(component)!).push({ path, parts });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const folds: Folds = { components: [], collections: new Map(), parts: new Map() };
|
|
71
|
+
for (const [group, tokens] of byGroup) {
|
|
72
|
+
const folded = new Set<string>();
|
|
73
|
+
const axes: AxisFold[] = [];
|
|
74
|
+
// The component's axes and their values, in precedence order (lib's `axesOf`).
|
|
75
|
+
for (const { axis, values } of collection.axesOf(group)) {
|
|
76
|
+
const partOf = (token: (typeof tokens)[number]) => token.parts.find((part) => part.axis === axis);
|
|
77
|
+
const carrying = tokens.filter((token) => partOf(token) !== undefined);
|
|
78
|
+
if (carrying.length === 0) continue;
|
|
79
|
+
const clash = carrying.find((token) => token.parts.some((part) => folded.has(part.axis)));
|
|
80
|
+
// The twin along this axis: the token its cell falls back to.
|
|
81
|
+
const lacking = carrying.find((token) => !partOf(token)!.twinExists);
|
|
82
|
+
const modes = folds.collections.get(axis) ?? [REST];
|
|
83
|
+
const merged = [...modes, ...values.filter((value) => !modes.includes(value))];
|
|
84
|
+
const why = unfold.includes(group)
|
|
85
|
+
? 'the component is laid out as one variable per part (layout=parts)'
|
|
86
|
+
: clash
|
|
87
|
+
? `"${clash.path}" carries it together with the folded "${clash.parts.find((part) => folded.has(part.axis))!.axis}"`
|
|
88
|
+
: lacking
|
|
89
|
+
? `no rest value: "${lacking.path}" needs "${partOf(lacking)!.twin}", which is not defined`
|
|
90
|
+
: merged.length > MAX_MODES
|
|
91
|
+
? `its values ${merged.slice(MAX_MODES).join(', ')} do not fit the ${folds.collections.has(axis) ? 'shared ' : ''}collection "${axis}", which already holds ${modes.length} mode${modes.length === 1 ? '' : 's'} (${modes.join(', ')}) and Figma allows ${MAX_MODES}`
|
|
92
|
+
: undefined;
|
|
93
|
+
axes.push({ axis, values, folded: why === undefined, ...(why ? { why } : {}) });
|
|
94
|
+
if (why) continue;
|
|
95
|
+
folded.add(axis);
|
|
96
|
+
folds.collections.set(axis, merged);
|
|
97
|
+
for (const token of carrying) {
|
|
98
|
+
const { twin: rest, key } = partOf(token)!;
|
|
99
|
+
const modesOf = folds.parts.get(rest) ?? folds.parts.set(rest, new Map()).get(rest)!;
|
|
100
|
+
const byMode = modesOf.get(axis) ?? modesOf.set(axis, new Map()).get(axis)!;
|
|
101
|
+
byMode.set(key, token.path);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
folds.components.push({ group, axes });
|
|
105
|
+
}
|
|
106
|
+
return folds;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** What a component's layout is, in one sentence, when some axis stays in the path; `undefined` when every axis folded. */
|
|
110
|
+
export function layoutSaid({ group, axes }: ComponentFold): string | undefined {
|
|
111
|
+
const stays = axes.filter((axis) => !axis.folded);
|
|
112
|
+
if (stays.length === 0) return undefined;
|
|
113
|
+
const folded = axes.filter((axis) => axis.folded);
|
|
114
|
+
return (
|
|
115
|
+
`"${group}": ` +
|
|
116
|
+
(folded.length > 0 ? `folds ${folded.map((axis) => `"${axis.axis}" (modes ${[REST, ...axis.values].join(', ')})`).join(', ')}; ` : '') +
|
|
117
|
+
stays.map((axis) => `"${axis.axis}" stays in the path, one variable per part: ${axis.why}`).join('; ') +
|
|
118
|
+
'.'
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Re-homes the tokens a fold touches. A rest token's variable becomes the head of a chain through the folded
|
|
124
|
+
* axes' collections, in vocabulary order (the later axis wins, as the later modifier does), above any chain the
|
|
125
|
+
* worlds already made. Each cell takes the part token's cell, an alias to the part token's own variable when it
|
|
126
|
+
* varies with a world and so keeps one, and falls back to the rest where there is no part token.
|
|
127
|
+
*
|
|
128
|
+
* Part tokens that fed a cell and need no variable of their own are dropped.
|
|
129
|
+
*/
|
|
130
|
+
export function foldTokens(
|
|
131
|
+
tokens: SumToken[],
|
|
132
|
+
folds: Folds,
|
|
133
|
+
dsName: string,
|
|
134
|
+
baseName: string,
|
|
135
|
+
): { tokens: SumToken[]; collections: SumCollection[] } {
|
|
136
|
+
const byPath = new Map(tokens.map((token) => [token.path, token]));
|
|
137
|
+
const dropped = new Set<string>();
|
|
138
|
+
const folded = new Map<string, SumToken>();
|
|
139
|
+
|
|
140
|
+
for (const [rest, axes] of folds.parts) {
|
|
141
|
+
const token = byPath.get(`${dsName}.${rest}`);
|
|
142
|
+
if (token?.routing.kind !== 'variable' || token.variables.length === 0) continue;
|
|
143
|
+
const { variableType } = token.routing;
|
|
144
|
+
const [head, ...below] = token.variables;
|
|
145
|
+
const worlds = head.collection !== baseName;
|
|
146
|
+
|
|
147
|
+
// The part token's cell: its value, or an alias to its variable when worlds give it one. A part token that
|
|
148
|
+
// is not a variable of this type is not folded: it stays what it is, and the mode falls back to the rest.
|
|
149
|
+
// A part that is a whole reference to its rest has no cell of its own (the mode falls back to the rest), and
|
|
150
|
+
// one that is a whole reference to a sibling part folded into the same rest takes that part's cell. `seen`
|
|
151
|
+
// stops a loop of siblings, which falls back to the rest as well.
|
|
152
|
+
const siblings = new Set(flatMap([...axes.values()], (byMode) => [...byMode.values()]));
|
|
153
|
+
const cellOf = (path: string, seen = new Set<string>([path])): SumCell | undefined => {
|
|
154
|
+
const part = byPath.get(`${dsName}.${path}`);
|
|
155
|
+
const first = part?.variables[0];
|
|
156
|
+
if (part?.routing.kind !== 'variable' || part.routing.variableType !== variableType || !first) return undefined;
|
|
157
|
+
if (first.collection !== baseName) return { kind: 'link', to: first.key };
|
|
158
|
+
dropped.add(part.path);
|
|
159
|
+
const cell = first.cells.get('default');
|
|
160
|
+
if (cell?.kind !== 'reference') return cell;
|
|
161
|
+
if (cell.to === token.path) return undefined;
|
|
162
|
+
const sibling = referencePath(cell.to);
|
|
163
|
+
if (!siblings.has(sibling)) return cell;
|
|
164
|
+
return seen.has(sibling) ? undefined : cellOf(sibling, seen.add(sibling));
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// The rest, kept as a link below when worlds vary it; otherwise its single cell is the axis's `rest`.
|
|
168
|
+
const demoted: SumVariable | undefined = worlds
|
|
169
|
+
? {
|
|
170
|
+
...head,
|
|
171
|
+
key: `${dsName}.${rest}~${head.collection}`,
|
|
172
|
+
name: linkVariableName(rest, head.collection),
|
|
173
|
+
codeSyntax: `var(${cssLinkName(cssPropertyName(token.path), head.collection)})`,
|
|
174
|
+
}
|
|
175
|
+
: undefined;
|
|
176
|
+
let under = demoted;
|
|
177
|
+
const rows: SumVariable[] = [];
|
|
178
|
+
const names = [...axes.keys()];
|
|
179
|
+
for (const [axis, byMode] of axes) {
|
|
180
|
+
const last = axis === names[names.length - 1];
|
|
181
|
+
const cells = new Map<string, SumCell>();
|
|
182
|
+
for (const mode of folds.collections.get(axis) as string[]) {
|
|
183
|
+
const part = byMode.get(mode);
|
|
184
|
+
const own = part === undefined ? undefined : cellOf(part);
|
|
185
|
+
cells.set(mode, own ?? (under ? { kind: 'link', to: under.key } : (head.cells.get('default') as SumCell)));
|
|
186
|
+
}
|
|
187
|
+
under = {
|
|
188
|
+
key: last ? token.path : `${dsName}.${rest}~${axis}`,
|
|
189
|
+
name: last ? head.name : linkVariableName(rest, axis),
|
|
190
|
+
collection: axis,
|
|
191
|
+
codeSyntax: last ? head.codeSyntax : `var(${cssLinkName(cssPropertyName(token.path), axis)})`,
|
|
192
|
+
cells,
|
|
193
|
+
};
|
|
194
|
+
rows.unshift(under);
|
|
195
|
+
}
|
|
196
|
+
folded.set(token.path, { ...token, variables: [...rows, ...(demoted ? [demoted] : []), ...below] });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
tokens: tokens.filter((token) => !dropped.has(token.path)).map((token) => folded.get(token.path) ?? token),
|
|
201
|
+
collections: [...folds.collections].map(([name, modes]): SumCollection => ({ name, axis: true, modes })),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A font stack is a fallback in Figma too: a text style takes the first family
|
|
3
|
+
* of the stack that Figma lists with the style asked for, as CSS takes the first
|
|
4
|
+
* the platform has. The generic families are CSS keywords, never in Figma's
|
|
5
|
+
* list, so they are named but not looked up.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const GENERIC_FAMILIES: ReadonlySet<string> = new Set([
|
|
9
|
+
'monospace',
|
|
10
|
+
'serif',
|
|
11
|
+
'sans-serif',
|
|
12
|
+
'cursive',
|
|
13
|
+
'fantasy',
|
|
14
|
+
'system-ui',
|
|
15
|
+
'ui-monospace',
|
|
16
|
+
'ui-sans-serif',
|
|
17
|
+
'ui-serif',
|
|
18
|
+
'ui-rounded',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const keyOf = (family: string, style: string): string => `${family}\u0000${style}`;
|
|
22
|
+
|
|
23
|
+
/** The fonts Figma lists, as a lookup by family and style. Read once per plan. */
|
|
24
|
+
export async function listFonts(): Promise<ReadonlySet<string>> {
|
|
25
|
+
const fonts = await figma.listAvailableFontsAsync();
|
|
26
|
+
return new Set(fonts.map(({ fontName }) => keyOf(fontName.family, fontName.style)));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FontChoice {
|
|
30
|
+
family: string;
|
|
31
|
+
/** The families before it that were passed over, in stack order. */
|
|
32
|
+
passedOver: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The first family that is listed with `style`, or nothing when none is. */
|
|
36
|
+
export function chooseFamily(stack: readonly string[], style: string, listed: ReadonlySet<string>): FontChoice | undefined {
|
|
37
|
+
const at = stack.findIndex((family) => !GENERIC_FAMILIES.has(family.toLowerCase()) && listed.has(keyOf(family, style)));
|
|
38
|
+
return at < 0 ? undefined : { family: stack[at], passedOver: stack.slice(0, at) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `"a"`, `"a" and "b"`, `"a", "b" and "c"`. */
|
|
42
|
+
function quoted(names: readonly string[]): string {
|
|
43
|
+
const all = names.map((name) => `"${name}"`);
|
|
44
|
+
return all.length < 2 ? all.join('') : `${all.slice(0, -1).join(', ')} and ${all[all.length - 1]}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const areNotAvailable = (names: readonly string[]): string => `${quoted(names)} ${names.length === 1 ? 'is' : 'are'} not available`;
|
|
48
|
+
|
|
49
|
+
/** What a style that fell back to a later family says. */
|
|
50
|
+
export function fallbackNote(path: string, choice: FontChoice): string {
|
|
51
|
+
return `"${path}" uses "${choice.family}": ${areNotAvailable(choice.passedOver)} in Figma.`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Why a style none of whose families loads is skipped. */
|
|
55
|
+
export function noFamilyReason(path: string, stack: readonly string[]): string {
|
|
56
|
+
return `"${path}" has no family Figma can load: ${areNotAvailable(stack)}.`;
|
|
57
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lock: what each name a file uses from a library resolved to, kept on the file's root.
|
|
3
|
+
*
|
|
4
|
+
* Shared plugin data in the `toghen` namespace, under `lock`, sharded (`shardedData.ts`) as kept templates are: a
|
|
5
|
+
* library of a few hundred components passes Figma's 100 kB per entry. It records keys, never what Toghen wrote: no
|
|
6
|
+
* hash, no time, no receipt. Receipts stay one per object. The shape is `reference.md`'s, *The lock*.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Lock, LockedLibrary } from '../../shared/protocol.js';
|
|
10
|
+
import { isPlainObject, readSharded, toAsciiJson, writeSharded } from './shardedData.js';
|
|
11
|
+
|
|
12
|
+
const KEY = 'lock';
|
|
13
|
+
|
|
14
|
+
/** Our own limit, as for templates: room for a few thousand keys, and for other plugins. */
|
|
15
|
+
const MAX_SHARDS = 8;
|
|
16
|
+
|
|
17
|
+
const BY: readonly LockedLibrary['by'][] = ['discovery', 'build', 'command line', 'person'];
|
|
18
|
+
|
|
19
|
+
const record = (value: unknown, valid: (entry: unknown) => boolean): boolean => isPlainObject(value) && Object.values(value).every(valid);
|
|
20
|
+
|
|
21
|
+
export function isLibrary(value: unknown): value is LockedLibrary {
|
|
22
|
+
return (
|
|
23
|
+
isPlainObject(value) &&
|
|
24
|
+
(value.fileKey === undefined || typeof value.fileKey === 'string') &&
|
|
25
|
+
(value.path === undefined || typeof value.path === 'string') &&
|
|
26
|
+
(value.id === undefined || typeof value.id === 'string') &&
|
|
27
|
+
BY.includes(value.by as LockedLibrary['by']) &&
|
|
28
|
+
record(value.components, (entry) => isPlainObject(entry) && typeof entry.key === 'string' && typeof entry.set === 'boolean') &&
|
|
29
|
+
record(
|
|
30
|
+
value.styles,
|
|
31
|
+
(entry) => isPlainObject(entry) && typeof entry.key === 'string' && ['TEXT', 'EFFECT', 'PAINT'].includes(entry.kind as string),
|
|
32
|
+
) &&
|
|
33
|
+
record(value.variables, (entry) => typeof entry === 'string')
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The file's lock. None, or a damaged record, reads as an empty one: nothing resolves through it. */
|
|
38
|
+
export function loadLock(): Lock {
|
|
39
|
+
try {
|
|
40
|
+
const payload = readSharded(KEY);
|
|
41
|
+
const parsed: unknown = payload ? JSON.parse(payload) : undefined;
|
|
42
|
+
if (
|
|
43
|
+
isPlainObject(parsed) &&
|
|
44
|
+
record(parsed.libraries, isLibrary) &&
|
|
45
|
+
(parsed.overrides === undefined || record(parsed.overrides, (entry) => typeof entry === 'string'))
|
|
46
|
+
) {
|
|
47
|
+
return parsed as unknown as Lock;
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// A damaged record is no record.
|
|
51
|
+
}
|
|
52
|
+
return { libraries: {} };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Writes the lock. Returns why it could not, rather than throwing. */
|
|
56
|
+
export function saveLock(lock: Lock): { ok: true } | { ok: false; reason: string } {
|
|
57
|
+
const written = writeSharded(KEY, toAsciiJson(lock), MAX_SHARDS);
|
|
58
|
+
if (written.ok) return { ok: true };
|
|
59
|
+
if (written.tooBig)
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
reason: `The lock comes to ${Math.round(written.bytes / 1024)} kB, more than this plugin keeps in one file. Forget a library first.`,
|
|
63
|
+
};
|
|
64
|
+
return { ok: false, reason: written.reason ?? 'Figma refused the write.' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A library's entry, or an empty one written by `by`. */
|
|
68
|
+
export const libraryIn = (lock: Lock, name: string, by: LockedLibrary['by']): LockedLibrary =>
|
|
69
|
+
lock.libraries[name] ?? { by, components: {}, styles: {}, variables: {} };
|
|
70
|
+
|
|
71
|
+
/** How a key is written where a name could be: `(key)"…"`, as a component file writes one. */
|
|
72
|
+
export const keyIn = (value: string): string | undefined => /^\(key\)"([^"]+)"$/.exec(value)?.[1];
|