@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.
Files changed (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -2
  3. package/cli/bin.mjs +19 -0
  4. package/cli/bridge.ts +243 -0
  5. package/cli/check.ts +62 -0
  6. package/cli/figcraft.ts +764 -0
  7. package/cli/figcraftApply.ts +1058 -0
  8. package/cli/figcraftFile.ts +476 -0
  9. package/cli/figcraftLibrary.ts +147 -0
  10. package/cli/figcraftRecord.ts +121 -0
  11. package/cli/figcraftScene.ts +661 -0
  12. package/cli/main.ts +218 -0
  13. package/cli/members.ts +65 -0
  14. package/cli/run.ts +756 -0
  15. package/component-files.md +665 -0
  16. package/dist/code.js +24147 -0
  17. package/dist/manifest.json +23 -0
  18. package/dist/ui.html +6918 -0
  19. package/guide.md +985 -0
  20. package/main/code.ts +50 -0
  21. package/main/components/apply.ts +704 -0
  22. package/main/components/endOfRun.ts +168 -0
  23. package/main/components/errors.ts +17 -0
  24. package/main/components/fieldWriter.ts +497 -0
  25. package/main/components/file.ts +263 -0
  26. package/main/components/receipt.ts +228 -0
  27. package/main/components/resolve.ts +1193 -0
  28. package/main/components/run.ts +273 -0
  29. package/main/components/snapshot.ts +341 -0
  30. package/main/components/stamp.ts +312 -0
  31. package/main/components/update.ts +465 -0
  32. package/main/dispatcher.ts +33 -0
  33. package/main/engines/batchRunner.ts +200 -0
  34. package/main/engines/ioAdapter.ts +132 -0
  35. package/main/engines/operationExecutor.ts +512 -0
  36. package/main/engines/propertyMapper.ts +221 -0
  37. package/main/engines/queryEngine.ts +45 -0
  38. package/main/engines/styleSyncEngine.ts +444 -0
  39. package/main/engines/variableSyncEngine.ts +561 -0
  40. package/main/handlers/auditSelection.ts +60 -0
  41. package/main/handlers/boundLayers.ts +51 -0
  42. package/main/handlers/cancelOperation.ts +18 -0
  43. package/main/handlers/executeApplyBindings.ts +69 -0
  44. package/main/handlers/executeScaffold.ts +59 -0
  45. package/main/handlers/executeStyleSync.ts +121 -0
  46. package/main/handlers/executeVariableSync.ts +82 -0
  47. package/main/handlers/exportDocument.ts +114 -0
  48. package/main/handlers/getSelection.ts +43 -0
  49. package/main/handlers/index.ts +30 -0
  50. package/main/handlers/inspectCorrespondence.ts +172 -0
  51. package/main/handlers/loadConventions.ts +86 -0
  52. package/main/handlers/loadDocument.ts +253 -0
  53. package/main/handlers/lock.ts +270 -0
  54. package/main/handlers/planApply.ts +157 -0
  55. package/main/handlers/planApplyBindings.ts +79 -0
  56. package/main/handlers/planScaffold.ts +84 -0
  57. package/main/handlers/planStyleSync.ts +49 -0
  58. package/main/handlers/planVariableSync.ts +49 -0
  59. package/main/handlers/readSelectionBindings.ts +160 -0
  60. package/main/handlers/resizeUi.ts +105 -0
  61. package/main/handlers/runComponent.ts +65 -0
  62. package/main/handlers/templates.ts +462 -0
  63. package/main/handlers/tokenGraph.ts +122 -0
  64. package/main/runtimeState.ts +66 -0
  65. package/main/utils/abortController.ts +11 -0
  66. package/main/utils/boundVariables.ts +84 -0
  67. package/main/utils/buildPayloads.ts +85 -0
  68. package/main/utils/correspondence.ts +658 -0
  69. package/main/utils/designSource.ts +122 -0
  70. package/main/utils/figmaCollectionIndex.ts +120 -0
  71. package/main/utils/figmaStyleAdapter.ts +268 -0
  72. package/main/utils/figmaTokenAdapter.ts +255 -0
  73. package/main/utils/flattenDesignSystem.ts +38 -0
  74. package/main/utils/folds.ts +203 -0
  75. package/main/utils/fontStack.ts +57 -0
  76. package/main/utils/lists.ts +4 -0
  77. package/main/utils/lockStore.ts +72 -0
  78. package/main/utils/moves.ts +67 -0
  79. package/main/utils/pageRoots.ts +58 -0
  80. package/main/utils/planSymbols.ts +96 -0
  81. package/main/utils/purpose.ts +351 -0
  82. package/main/utils/recentStore.ts +166 -0
  83. package/main/utils/resolverModes.ts +271 -0
  84. package/main/utils/rootRecord.ts +137 -0
  85. package/main/utils/shardedData.ts +142 -0
  86. package/main/utils/standing.ts +106 -0
  87. package/main/utils/styleTarget.ts +51 -0
  88. package/main/utils/sum.ts +612 -0
  89. package/main/utils/symbolicIdRegistry.ts +26 -0
  90. package/main/utils/syncState.ts +133 -0
  91. package/main/utils/templateStore.ts +73 -0
  92. package/main/utils/tokenPath.ts +28 -0
  93. package/main/utils/tokenStamps.ts +91 -0
  94. package/manifest.json +15 -0
  95. package/package.json +64 -4
  96. package/reference.md +1609 -0
  97. package/shared/components/artboard.ts +126 -0
  98. package/shared/components/axes.ts +420 -0
  99. package/shared/components/bindings.ts +64 -0
  100. package/shared/components/blueprint.ts +248 -0
  101. package/shared/components/check.ts +705 -0
  102. package/shared/components/diff.ts +668 -0
  103. package/shared/components/fields.ts +404 -0
  104. package/shared/components/groups.ts +306 -0
  105. package/shared/components/index.ts +140 -0
  106. package/shared/components/members.ts +280 -0
  107. package/shared/components/model.ts +273 -0
  108. package/shared/components/order.ts +53 -0
  109. package/shared/components/read.ts +480 -0
  110. package/shared/components/stack.ts +124 -0
  111. package/shared/components/steps.ts +186 -0
  112. package/shared/components/validate.ts +548 -0
  113. package/shared/conventions.ts +28 -0
  114. package/shared/inputs.ts +40 -0
  115. package/shared/operations.ts +233 -0
  116. package/shared/pendingNames.ts +39 -0
  117. package/shared/protocol.ts +588 -0
  118. package/shared/tokenTypeMap.ts +21 -0
@@ -0,0 +1,612 @@
1
+ /**
2
+ * The sum: where every token lives in Figma, decided before Figma is looked at.
3
+ *
4
+ * Variable sync used to build the product, one collection with a mode per
5
+ * combination of every modifier's contexts. Figma's own model is the sum: a frame
6
+ * sets a mode per collection, its children inherit each independently, and an
7
+ * alias across collections resolves at the node that uses it. So this builds a
8
+ * base collection (one mode, `default`) and one collection per modifier (a mode
9
+ * per context, the default context first), and gives each token a home from what
10
+ * lib reports about it, `contextsOf()`:
11
+ *
12
+ * | Links | Home |
13
+ * |---|---|
14
+ * | none | the base collection; a base that is a reference is an alias |
15
+ * | one | that modifier's collection; a context that does not define it carries the base |
16
+ * | two or more | a chain of aliases: the head, named for the token, in the last link's collection; each earlier link `<path>--<modifier>` in its own |
17
+ *
18
+ * Nothing here touches Figma, so the Inspector, the plan and the style plan all
19
+ * ask one place, and the tests need no mock. What Figma holds is `standing.ts`'s.
20
+ *
21
+ * A token that has no home is not filled and not guessed: a hole, a `$type` that
22
+ * varies, a token lib reports as non-factorable. Each is a `refused` routing with
23
+ * the reason in words, and the engine reports it and writes nothing for it.
24
+ */
25
+
26
+ import {
27
+ cssLinkName,
28
+ cssPropertyName,
29
+ holdsReference,
30
+ InvalidTokenError,
31
+ normalizeTokenValue,
32
+ parseReferencePath,
33
+ referencePath,
34
+ referenceTarget,
35
+ type Resolver,
36
+ type ResolverInput,
37
+ } from '@toghen/lib';
38
+ import type { TokenTypeConversionMap } from '../../shared/tokenTypeMap.js';
39
+ import { flatMap } from './lists.js';
40
+ import { foldsOf, foldTokens, unfoldedFor, type ComponentFold } from './folds.js';
41
+ import { figmaContexts, pathsInAnyMode, readToken, referrersOf, resolveSumModes } from './resolverModes.js';
42
+ import { linkVariableName, tokenPathToVariableName } from './tokenPath.js';
43
+ import { assignPurposes, scopesIn, type Declared } from './purpose.js';
44
+
45
+ /**
46
+ * Where a token's type sends it — decided before any value is looked at.
47
+ *
48
+ * The failing kinds are kept apart rather than collapsed into one "cannot
49
+ * sync", because the engine reports them differently and has always done so: a
50
+ * type that varies is a `WARNING`, an unsupported type is a `SKIP` that names
51
+ * it, and a style-routed token is a `SKIP` that points at the other phase.
52
+ */
53
+ export type TokenRouting =
54
+ | { kind: 'variable'; variableType: VariableResolvedDataType }
55
+ | { kind: 'style' }
56
+ /** The same path has different `$type`s in different contexts. */
57
+ | { kind: 'type-conflict'; types: string[] }
58
+ | { kind: 'unsupported'; tokenType: string | undefined }
59
+ /** Routed to a variable by the conversion map, but with no `variableType`. */
60
+ | { kind: 'missing-route'; tokenType: string }
61
+ /**
62
+ * A variable token with no home: a hole, a non-factorable value, or one that cannot be read in a context.
63
+ * `skip`: refused for what it asks of Figma (a scope list, an opacity outside 0 to 1), and said as a skip and not a warning.
64
+ */
65
+ | { kind: 'refused'; reason: string; skip?: true };
66
+
67
+ /**
68
+ * What one mode of one variable holds, as the document spells it.
69
+ *
70
+ * `reference` and `link` become aliases when Figma can hold one. A reference keeps
71
+ * the value it resolves to in this context (`value`), for the case where it cannot:
72
+ * the target is not a variable, or is another variable type.
73
+ */
74
+ export type SumCell =
75
+ /** `refused`: the core's reason, where it cannot compute the value for the type; the cell cannot be written. */
76
+ | { kind: 'value'; value: unknown; refused?: string }
77
+ | { kind: 'reference'; to: string; value?: unknown; refused?: string }
78
+ /** The next link of a chain: always the same token, so always the same variable type. */
79
+ | { kind: 'link'; to: string };
80
+
81
+ export interface SumVariable {
82
+ /** What its stamp names it by: the token's lookup path; a chain link's generated lookup path (`ds.tone.alert~scheme`). */
83
+ key: string;
84
+ name: string;
85
+ /** The Figma collection it lives in. */
86
+ collection: string;
87
+ /** `var(--surface-accent)`: the CSS spelling, which lib owns. */
88
+ codeSyntax: string;
89
+ /** One cell per mode of its collection, in the collection's order. */
90
+ cells: Map<string, SumCell>;
91
+ }
92
+
93
+ export interface SumCollection {
94
+ name: string;
95
+ /** The modifier it stands for; absent for the base collection. */
96
+ modifier?: string;
97
+ /** Set for the collection of a folded part axis (`folds.ts`), named after the axis. */
98
+ axis?: true;
99
+ /** The default context first: Figma's default mode is `modes[0]`, fixed at creation. */
100
+ modes: string[];
101
+ }
102
+
103
+ /**
104
+ * What a variable is for (`purpose.ts`): the Figma fields it can fill, and whether it is an opacity, a number from 0
105
+ * to 1 that Figma holds as a percentage. `source` says which of the four readings answered.
106
+ */
107
+ export interface Purpose {
108
+ scopes: VariableScope[];
109
+ opacity: boolean;
110
+ source: 'declaration' | 'name' | 'reference' | 'type';
111
+ }
112
+
113
+ export interface SumToken {
114
+ /** The lookup path, `ds.color.brand`: the token a stamp names. */
115
+ path: string;
116
+ /** The document's `$type`, or null when the contexts disagree about it. */
117
+ type: string | null;
118
+ routing: TokenRouting;
119
+ /** What the variable is for. Set on every token that routes to a variable. */
120
+ purpose?: Purpose;
121
+ /** The head first, then each earlier link of a chain. Empty unless `routing` is `variable`. */
122
+ variables: SumVariable[];
123
+ }
124
+
125
+ export interface Sum {
126
+ /** The leading segment of every lookup path. Not the Figma collection's name. */
127
+ dsName: string;
128
+ /** The base collection first, then one per modifier in the order the document declares them. */
129
+ collections: SumCollection[];
130
+ tokens: SumToken[];
131
+ tokenByPath: Map<string, SumToken>;
132
+ variableByKey: Map<string, SumVariable>;
133
+ /** How each component's parts are laid out: which axes folded, and why the others did not. */
134
+ layouts: ComponentFold[];
135
+ /** What the purposes of the variables have to say apart from a token's refusal. */
136
+ warnings: string[];
137
+ }
138
+
139
+ /** The routing of a type, or the several types a token has. */
140
+ function routeTypes(types: readonly string[], conversionMap: TokenTypeConversionMap): TokenRouting {
141
+ if (types.length > 1) return { kind: 'type-conflict', types: [...types] };
142
+
143
+ const tokenType = types[0];
144
+ const route = tokenType === undefined ? undefined : conversionMap[tokenType];
145
+
146
+ if (!route || route.target === 'skip') return { kind: 'unsupported', tokenType };
147
+ if (route.target === 'style') return { kind: 'style' };
148
+ if (!route.variableType) return { kind: 'missing-route', tokenType: tokenType as string };
149
+
150
+ return { kind: 'variable', variableType: route.variableType };
151
+ }
152
+
153
+ /** The colour keyword that stands for the text colour around an element. */
154
+ function isCurrentColor(value: unknown): boolean {
155
+ return typeof value === 'string' && value.trim().toLowerCase() === 'currentcolor';
156
+ }
157
+
158
+ /** A letter spacing in `em`, structured as lib stores it. */
159
+ function isEmLetterSpacing(value: unknown): boolean {
160
+ return typeof value === 'object' && value !== null && (value as { unit?: unknown }).unit === 'em';
161
+ }
162
+
163
+ /** The path a value names when it is one whole reference, checked by the core's grammar; otherwise undefined. */
164
+ function wholeTarget(value: unknown): string | undefined {
165
+ const target = referenceTarget(value);
166
+ return target === undefined ? undefined : parseReferencePath(target).join('.');
167
+ }
168
+
169
+ /** What a definition of a token says Figma writes where it cannot hold the CSS value: `$extensions["com.toghen"].figma.value`. */
170
+ function figmaValueIn(extensions: unknown): unknown {
171
+ const figma = (extensions as { 'com.toghen'?: { figma?: unknown } } | null | undefined)?.['com.toghen']?.figma;
172
+ return typeof figma === 'object' && figma !== null ? (figma as { value?: unknown }).value : undefined;
173
+ }
174
+
175
+ /** Whether any definition of a token carries a `figma.value`. A token whose extensions lib cannot factor is looked at cell by cell. */
176
+ function namesFigmaValue(about: ReturnType<Resolver['contextsOf']>): boolean {
177
+ const chain = about.chains.$extensions;
178
+ if (!chain) return about.nonFactorable.some((entry) => entry.property === '$extensions');
179
+ return [
180
+ chain.base,
181
+ ...flatMap(chain.links, (link) => Object.values(link.steps).map((step) => ('value' in step ? step.value : undefined))),
182
+ ].some((extensions) => figmaValueIn(extensions) !== undefined);
183
+ }
184
+
185
+ /** Where a cell is: a modifier's context, or the base (neither given). */
186
+ interface CellAt {
187
+ modifier?: string;
188
+ context?: string;
189
+ }
190
+
191
+ /** What the document says at a cell of a token: its resolved type and `figma.value` there, and the mode that is (for the type of what a key names). */
192
+ interface Named {
193
+ at(refPath: string, cell: CellAt): { type: string | undefined; key: unknown; mode: string | undefined };
194
+ /** The type of the token a reference path names, in a mode if it has one there. */
195
+ typeOf(refPath: string, mode: string | undefined): string | undefined;
196
+ }
197
+
198
+ /**
199
+ * Builds the sum of a document.
200
+ *
201
+ * Throws when lib cannot build it: a modifier with no `default` (see
202
+ * `resolveSumModes`). The engines turn that into a plan that says so.
203
+ *
204
+ * @param baseName - The Figma name of the base collection, not the lookup prefix
205
+ */
206
+ export function buildSum(resolver: Resolver, baseName: string, conversionMap: TokenTypeConversionMap): Sum {
207
+ const resolution = resolveSumModes(resolver);
208
+ const { ds, dsCollectionName: dsName, modeNames } = resolution;
209
+ const collection = ds.getCollection(dsName);
210
+ if (!collection) throw new Error('resolveContextsToDesignSystem produced no collection');
211
+
212
+ const collections: SumCollection[] = [{ name: baseName, modes: ['default'] }];
213
+ const defaults: Record<string, string | undefined> = {};
214
+ // The contexts of each modifier that Figma does not get (its `figma.include`): CSS writes them, the sum does not.
215
+ const leftOut = new Map<string, Set<string>>();
216
+ for (const modifier of resolver.getModifiers()) {
217
+ defaults[modifier.name] = modifier.default;
218
+ const modes = figmaContexts(modifier);
219
+ collections.push({ name: modifier.name, modifier: modifier.name, modes });
220
+ leftOut.set(modifier.name, new Set(modifier.contexts.filter((context) => !modes.includes(context))));
221
+ }
222
+ const leftOutModes = new Set<string>();
223
+ for (const [modifier, contexts] of leftOut)
224
+ for (const context of contexts) leftOutModes.add(resolver.getModeName({ [modifier]: context }));
225
+
226
+ // Every path in any mode Figma gets, in first-appearance order, so a plan is stable between runs;
227
+ // and the types it has there. A token that only the contexts Figma does not get define is not here: it has no variable and is not reported.
228
+ const paths = pathsInAnyMode(
229
+ resolution,
230
+ modeNames.filter((name) => !leftOutModes.has(name)),
231
+ );
232
+ const typesByPath = new Map<string, Set<string>>();
233
+ for (const [refPath, modes] of paths) {
234
+ const types = new Set<string>();
235
+ for (const mode of modes) {
236
+ const type = readToken(ds, `${dsName}.${refPath}`, mode)?.$type;
237
+ if (type !== undefined) types.add(type);
238
+ }
239
+ typesByPath.set(refPath, types);
240
+ }
241
+ // Who refers to each token, in any mode Figma gets: what a variable is for flows along references.
242
+ const referrers = referrersOf(resolution, paths);
243
+
244
+ const tokens: SumToken[] = [];
245
+ const tokenByPath = new Map<string, SumToken>();
246
+ const variableByKey = new Map<string, SumVariable>();
247
+ /** What each token's own definition says about its scopes. */
248
+ const declared = new Map<string, Declared>();
249
+
250
+ // The mode of a cell is lib's to name; a cell's own definition of the token is read from the system lib resolved for it.
251
+ const modeOf = ({ modifier, context }: CellAt): string | undefined => {
252
+ if (modifier === undefined || context === undefined) return collection.getDefaultMode();
253
+ try {
254
+ return resolver.getModeName({ [modifier]: context });
255
+ } catch {
256
+ return undefined;
257
+ }
258
+ };
259
+ const named: Named = {
260
+ at: (refPath, cell) => {
261
+ const mode = modeOf(cell);
262
+ if (mode === undefined) return { type: undefined, key: undefined, mode };
263
+ return {
264
+ type: readToken(ds, `${dsName}.${refPath}`, mode)?.$type,
265
+ key: figmaValueIn(collection.getToken(refPath, mode)?.$extensions),
266
+ mode,
267
+ };
268
+ },
269
+ typeOf: (refPath, mode) => {
270
+ for (const candidate of mode === undefined ? modeNames : [mode, ...modeNames]) {
271
+ const type = readToken(ds, `${dsName}.${refPath}`, candidate)?.$type;
272
+ if (type !== undefined) return type;
273
+ }
274
+ return undefined;
275
+ },
276
+ };
277
+
278
+ // A token is judged by the tokens it refers to, so each is built when first asked for, wherever it comes in the document.
279
+ const built = new Map<string, SumToken>();
280
+ const building = new Set<string>();
281
+ const targetOf = (path: string): SumToken | undefined => {
282
+ // The sum holds one collection, so every lookup path is in it.
283
+ const refPath = referencePath(path);
284
+ const types = typesByPath.get(refPath);
285
+ if (!types || building.has(refPath)) return undefined;
286
+ let token = built.get(refPath);
287
+ if (!token) {
288
+ building.add(refPath);
289
+ token = buildToken(resolver, dsName, baseName, refPath, types, conversionMap, defaults, leftOut, named, targetOf, declared);
290
+ building.delete(refPath);
291
+ built.set(refPath, token);
292
+ }
293
+ return token;
294
+ };
295
+
296
+ for (const refPath of typesByPath.keys()) tokens.push(targetOf(`${dsName}.${refPath}`) as SumToken);
297
+
298
+ // Parts: a folded axis is a collection of its own, and the part tokens that fed it have no variable.
299
+ const folds = foldsOf(collection, [...typesByPath.keys()], unfoldedFor(resolver));
300
+ const taken = new Set(collections.map((entry) => entry.name));
301
+ for (const axis of folds.collections.keys()) {
302
+ if (taken.has(axis))
303
+ throw new Error(
304
+ `The part axis "${axis}" folds into a collection of that name, and the base collection or a modifier already has it. Rename the axis in the parts vocabulary, or lay its component out as one variable per part (layout=parts).`,
305
+ );
306
+ }
307
+ const folded = foldTokens(tokens, folds, dsName, baseName);
308
+ collections.push(...folded.collections);
309
+
310
+ // What each variable is for. A part a fold took into another token's variable is judged by that token.
311
+ const kept = new Set(folded.tokens.map((token) => token.path));
312
+ const partsOf = new Map<string, string>();
313
+ for (const [rest, axes] of folds.parts) {
314
+ for (const byMode of axes.values()) {
315
+ for (const part of byMode.values()) if (!kept.has(`${dsName}.${part}`)) partsOf.set(`${dsName}.${part}`, `${dsName}.${rest}`);
316
+ }
317
+ }
318
+ const typeOf = (path: string): string | undefined => {
319
+ const types = typesByPath.get(referencePath(path));
320
+ return types?.size === 1 ? [...types][0] : undefined;
321
+ };
322
+ const warnings = assignPurposes({ collection, ds, baseName, tokens: folded.tokens, partsOf, declared, referrers, typeOf });
323
+
324
+ for (const token of folded.tokens) {
325
+ tokenByPath.set(token.path, token);
326
+ for (const variable of token.variables) variableByKey.set(variable.key, variable);
327
+ }
328
+
329
+ return { dsName, collections, tokens: folded.tokens, tokenByPath, variableByKey, layouts: folds.components, warnings };
330
+ }
331
+
332
+ /** What a token's own definition says about its scopes: the list on its base, and the context that redefines it. */
333
+ function declarationIn(contexts: ReturnType<Resolver['contextsOf']>, out: (modifier: string, context: string) => boolean): Declared {
334
+ const chain = contexts.chains.$extensions;
335
+ if (!chain) return {};
336
+ const base = scopesIn(chain.base);
337
+ for (const link of chain.links) {
338
+ for (const [context, step] of Object.entries(link.steps)) {
339
+ if (out(link.modifier, context) || !('value' in step)) continue;
340
+ const found = scopesIn(step.value);
341
+ if (found !== undefined && JSON.stringify(found) !== JSON.stringify(base)) return { conflict: `${link.modifier}=${context}` };
342
+ }
343
+ }
344
+ return base === undefined ? {} : { scopes: base };
345
+ }
346
+
347
+ function buildToken(
348
+ resolver: Resolver,
349
+ dsName: string,
350
+ baseName: string,
351
+ refPath: string,
352
+ modeTypes: ReadonlySet<string>,
353
+ conversionMap: TokenTypeConversionMap,
354
+ defaults: Readonly<Record<string, string | undefined>>,
355
+ leftOut: ReadonlyMap<string, ReadonlySet<string>>,
356
+ named: Named,
357
+ targetOf: (path: string) => SumToken | undefined,
358
+ declared: Map<string, Declared>,
359
+ ): SumToken {
360
+ const path = `${dsName}.${refPath}`;
361
+ /** Whether Figma does not get this context of this modifier: nothing of it is written. */
362
+ const out = (modifier: string, context: string): boolean => leftOut.get(modifier)?.has(context) === true;
363
+
364
+ // The types the token has anywhere: in any mode of the sum, and in any step of
365
+ // its `$type` chain (a context that sets only the type, which the sum may not reach together).
366
+ const types = new Set<string>(modeTypes);
367
+
368
+ let contexts: ReturnType<Resolver['contextsOf']> | undefined;
369
+ let contextsError: string | undefined;
370
+ try {
371
+ contexts = resolver.contextsOf(refPath);
372
+ } catch (error) {
373
+ contextsError = error instanceof Error ? error.message : String(error);
374
+ }
375
+
376
+ if (contexts) declared.set(refPath, declarationIn(contexts, out));
377
+
378
+ const typeChain = contexts?.chains.$type;
379
+ if (typeChain) {
380
+ for (const link of typeChain.links) {
381
+ for (const [context, step] of Object.entries(link.steps)) {
382
+ if (!out(link.modifier, context) && 'value' in step && typeof step.value === 'string') types.add(step.value);
383
+ }
384
+ }
385
+ if (typeof typeChain.base === 'string') types.add(typeChain.base);
386
+ }
387
+
388
+ // `figma.value`: a definition says what Figma writes where it cannot hold the CSS value. A token with no `$type` takes the type of what its keys name.
389
+ const keyed = contexts !== undefined && namesFigmaValue(contexts);
390
+ const cells = (): CellAt[] => {
391
+ const chain = contexts?.chains.$value;
392
+ if (!chain) return [];
393
+ if (chain.links.length === 0) return [{}];
394
+ return flatMap(chain.links, (link) =>
395
+ Object.entries(link.steps)
396
+ .filter(([context, step]) => 'value' in step && !out(link.modifier, context))
397
+ .map(([context]): CellAt => ({ modifier: link.modifier, context })),
398
+ );
399
+ };
400
+ let keyProblem: string | undefined;
401
+ if (keyed && types.has('')) {
402
+ const untyped = cells()
403
+ .map((cell) => named.at(refPath, cell))
404
+ .filter((found) => found.type === '');
405
+ if (untyped.length > 0 && untyped.every((found) => found.key !== undefined)) {
406
+ types.delete('');
407
+ for (const { key, mode } of untyped) {
408
+ const target = wholeTarget(key);
409
+ const given = target === undefined ? undefined : named.typeOf(target, mode);
410
+ if (given) types.add(given);
411
+ else
412
+ keyProblem = `"${path}" has no $type, and its figma.value ${JSON.stringify(key)} does not give it one: only a reference to a token has a type to give.`;
413
+ }
414
+ }
415
+ }
416
+
417
+ const routing = routeTypes([...types], conversionMap);
418
+ const type = types.size === 1 ? [...types][0] : null;
419
+ const unhomed = (reason: string): SumToken => ({ path, type, routing: { kind: 'refused', reason }, variables: [] });
420
+ if (keyProblem) return unhomed(keyProblem);
421
+
422
+ // Only a variable token needs a home; a style is built from the default mode.
423
+ if (routing.kind !== 'variable') return { path, type, routing, variables: [] };
424
+
425
+ if (!contexts) return unhomed(`lib cannot read this token across contexts: ${contextsError}`);
426
+ const unfactorable = contexts.nonFactorable.find((entry) => entry.property === '$value');
427
+ if (unfactorable) {
428
+ // A chain cannot hold it, and binding it early, one variable per cell, would bring the product back for this one token.
429
+ return unhomed(`${unfactorable.detail} A chain cannot hold it, so it is not written.`);
430
+ }
431
+ const chain = contexts.chains.$value;
432
+ if (!chain) return unhomed('The token defines no value in any context.');
433
+ // A hole in a context Figma does not get is not one: nothing is written there.
434
+ const holes = chain.holes.filter((hole) => !Object.entries(hole).some(([modifier, context]) => out(modifier, context)));
435
+ if (holes.length > 0) {
436
+ const where = holes
437
+ .map((hole) =>
438
+ Object.entries(hole)
439
+ .map(([modifier, context]) => `${modifier}=${context}`)
440
+ .join(' '),
441
+ )
442
+ .join(', ');
443
+ // Only a context defines it and the default is a hole: a variable needs a value in every mode.
444
+ const defined = flatMap(chain.links, (link) =>
445
+ Object.entries(link.steps)
446
+ .filter(([context, step]) => 'value' in step && !out(link.modifier, context))
447
+ .map(([context]) => `${link.modifier}=${context}`),
448
+ );
449
+ if (
450
+ chain.base === undefined &&
451
+ defined.length > 0 &&
452
+ holes.some((hole) => Object.entries(hole).every(([modifier, context]) => defaults[modifier] === context))
453
+ ) {
454
+ return unhomed(
455
+ `"${path}" is defined only in ${defined.join(', ')} and has no value where ${where}, the default; a Figma variable needs a value in every mode, so it is skipped.`,
456
+ );
457
+ }
458
+ return unhomed(`The token has no value where ${where}, and a hole is not filled or guessed, so it is not written.`);
459
+ }
460
+
461
+ const name = tokenPathToVariableName(refPath);
462
+ // `currentColor` has no value until an element uses it, and Figma has no equivalent: no variable holds it,
463
+ // directly or through a reference that resolves to it.
464
+ // A letter spacing in `em` is the same: a variable bound to letter spacing carries pixels, and `em` has none without a font size.
465
+ const unholdable = (value: unknown): boolean =>
466
+ (type === 'color' && isCurrentColor(value)) || (type === 'letterSpacing' && isEmLetterSpacing(value));
467
+ // A reference to a token that is a variable (of this variable type) is an alias to it, whatever the chain beneath it ends in.
468
+ const aliasesVariable = (cell: SumCell): boolean => {
469
+ if (cell.kind !== 'reference') return false;
470
+ const target = targetOf(cell.to);
471
+ return target?.routing.kind === 'variable' && target.routing.variableType === routing.variableType && target.variables.length > 0;
472
+ };
473
+ const holds = (token: SumToken, test: (value: unknown) => boolean): boolean =>
474
+ token.variables.some((variable) =>
475
+ [...variable.cells.values()].some((cell) => cell.kind !== 'link' && !aliasesVariable(cell) && test(cell.value)),
476
+ );
477
+ const withoutUnholdable = (token: SumToken): SumToken =>
478
+ type === 'color' && holds(token, isCurrentColor)
479
+ ? unhomed(`"${path}" is currentColor, which Figma cannot hold; skipped.`)
480
+ : type === 'letterSpacing' && holds(token, isEmLetterSpacing)
481
+ ? unhomed(`"${path}" is a letter spacing in em, and a Figma variable bound to letter spacing carries pixels; skipped.`)
482
+ : token;
483
+ const cellFor = (raw: unknown, valuePath: string, input: ResolverInput, at: CellAt = {}): SumCell => {
484
+ // What the value is in this context with everything followed; undefined when lib cannot say, with the core's
485
+ // reason where it refuses to compute the value for the type.
486
+ let refused: string | undefined;
487
+ const resolved = (): unknown => {
488
+ try {
489
+ return resolver.valueAt(valuePath, input);
490
+ } catch (error) {
491
+ if (error instanceof InvalidTokenError) refused = error.message;
492
+ return undefined;
493
+ }
494
+ };
495
+ const key = keyed ? named.at(refPath, at) : undefined;
496
+ let cell: SumCell | undefined;
497
+ if (wholeTarget(raw) !== undefined) {
498
+ const value = resolved();
499
+ cell = { kind: 'reference', to: `${dsName}.${wholeTarget(raw)}`, value, ...(refused === undefined ? {} : { refused }) };
500
+ } else if (holdsReference(raw)) {
501
+ const value = resolved();
502
+ if (refused !== undefined) cell = { kind: 'value', value, refused };
503
+ else if (value === undefined) throw new Error(`The value "${JSON.stringify(raw)}" cannot be resolved in ${JSON.stringify(input)}.`);
504
+ else cell = { kind: 'value', value };
505
+ } else if (key?.key === undefined) {
506
+ // A chain's value comes from lib in the shape it stores.
507
+ return { kind: 'value', value: raw };
508
+ } else {
509
+ // Where a key is given, a value lib cannot read as the type is one Figma cannot hold. The type may be the key's,
510
+ // given to a token with no $type of its own, which lib keeps as written: so the value is checked against it here.
511
+ try {
512
+ cell = { kind: 'value', value: normalizeTokenValue(type as string, raw, refPath) };
513
+ } catch {
514
+ cell = undefined;
515
+ }
516
+ }
517
+ return key?.key === undefined ? (cell as SumCell) : keyedCell(key.key, cell, at, input);
518
+ };
519
+ /** Whether the definition a cell's value comes from carries its own `$extensions`, and not the ones another definition of the token gives it by falling through. */
520
+ const definesKey = ({ modifier, context }: CellAt): boolean => {
521
+ const extensions = contexts?.chains.$extensions;
522
+ if (!extensions || modifier === undefined) return true;
523
+ const link = extensions.links.find((candidate) => candidate.modifier === modifier);
524
+ const step = link?.steps[context as string];
525
+ // A step that carries the base to a context that defines nothing says so.
526
+ return step !== undefined && 'value' in step && !step.base;
527
+ };
528
+ /** What a cell that Figma cannot hold is written as instead: the value its key names, checked like any reference. Throws, naming the token and the context. */
529
+ const keyedCell = (key: unknown, own: SumCell | undefined, at: CellAt, input: ResolverInput): SumCell => {
530
+ const where = `"${path}"${at.modifier === undefined ? '' : ` in ${at.modifier}=${at.context}`}`;
531
+ // A reference to a variable follows the modes, so it never takes a key of its own: one the cell only inherits says nothing about it.
532
+ if (own !== undefined && own.kind === 'reference' && aliasesVariable(own) && unholdable(own.value)) {
533
+ if (!definesKey(at)) return own;
534
+ throw new Error(
535
+ `${where} has a figma.value, but it is a reference to "${own.to}", which Figma holds as a variable; remove the key, so that it follows the modes.`,
536
+ );
537
+ }
538
+ if (own !== undefined && own.kind !== 'link' && own.refused === undefined && !unholdable(own.value)) {
539
+ // A key the cell only inherits from another definition of the token says nothing about this one.
540
+ if (!definesKey(at)) return own;
541
+ throw new Error(
542
+ `${where} has a figma.value, but its value is one Figma can hold; remove the key, so that the value stays the one both outputs write.`,
543
+ );
544
+ }
545
+ const said = `${where}: figma.value ${JSON.stringify(key)}`;
546
+ let cell: SumCell;
547
+ const target = wholeTarget(key);
548
+ if (target !== undefined) {
549
+ let value: unknown;
550
+ try {
551
+ value = resolver.valueAt(target, input);
552
+ } catch (error) {
553
+ throw new Error(`${said} cannot be resolved: ${error instanceof Error ? error.message : String(error)}`);
554
+ }
555
+ const targetType = named.typeOf(target, named.at(refPath, at).mode);
556
+ if (targetType !== type)
557
+ throw new Error(
558
+ `${said} is ${targetType ? `a ${targetType}` : 'not a token'}, and the token is ${type ? `a ${type}` : 'untyped'}.`,
559
+ );
560
+ cell = { kind: 'reference', to: `${dsName}.${target}`, value };
561
+ } else {
562
+ try {
563
+ cell = { kind: 'value', value: normalizeTokenValue(type as string, key, refPath) };
564
+ } catch (error) {
565
+ throw new Error(`${said} is not a value of the token's type, ${type}: ${error instanceof Error ? error.message : String(error)}`);
566
+ }
567
+ }
568
+ if (unholdable(cell.value)) throw new Error(`${said} is a value Figma cannot hold either.`);
569
+ return cell;
570
+ };
571
+
572
+ try {
573
+ // No link: the base, in the base collection.
574
+ if (chain.links.length === 0) {
575
+ return withoutUnholdable({
576
+ path,
577
+ type,
578
+ routing,
579
+ variables: [
580
+ {
581
+ key: path,
582
+ name,
583
+ collection: baseName,
584
+ codeSyntax: `var(${cssPropertyName(path)})`,
585
+ cells: new Map([['default', cellFor(chain.base, refPath, {}, {})]]),
586
+ },
587
+ ],
588
+ });
589
+ }
590
+
591
+ // One link or a chain: the head (links[0], the last-winning modifier) under the token's name, each earlier link under `<path>--<modifier>`.
592
+ const variables = chain.links.map((link, index): SumVariable => {
593
+ const cells = new Map<string, SumCell>();
594
+ for (const [context, step] of Object.entries(link.steps)) {
595
+ if (out(link.modifier, context)) continue;
596
+ if ('value' in step)
597
+ cells.set(context, cellFor(step.value, link.path, { [link.modifier]: context }, { modifier: link.modifier, context }));
598
+ else if ('fallThrough' in step) cells.set(context, { kind: 'link', to: `${dsName}.${step.fallThrough}` });
599
+ }
600
+ return {
601
+ key: index === 0 ? path : `${dsName}.${link.path}`,
602
+ name: index === 0 ? name : linkVariableName(refPath, link.modifier),
603
+ collection: link.modifier,
604
+ codeSyntax: index === 0 ? `var(${cssPropertyName(path)})` : `var(${cssLinkName(cssPropertyName(path), link.modifier)})`,
605
+ cells,
606
+ };
607
+ });
608
+ return withoutUnholdable({ path, type, routing, variables });
609
+ } catch (error) {
610
+ return unhomed(error instanceof Error ? error.message : String(error));
611
+ }
612
+ }
@@ -0,0 +1,26 @@
1
+ export class SymbolicIdRegistry {
2
+ private readonly map = new Map<string, string>();
3
+
4
+ register(symbolicId: string, realId: string): void {
5
+ if (!symbolicId.startsWith('$')) {
6
+ throw new Error(`Invalid symbolic ID (must start with $): ${symbolicId}`);
7
+ }
8
+ if (!realId) {
9
+ throw new Error(`Invalid real ID for symbolic ID ${symbolicId}`);
10
+ }
11
+
12
+ this.map.set(symbolicId, realId);
13
+ }
14
+
15
+ resolve(id: string): string {
16
+ if (!id.startsWith('$')) return id;
17
+
18
+ const real = this.map.get(id);
19
+ if (!real) throw new Error(`Unresolved symbolic ID: ${id}`);
20
+ return real;
21
+ }
22
+
23
+ has(symbolicId: string): boolean {
24
+ return this.map.has(symbolicId);
25
+ }
26
+ }