@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,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The last few resolver documents this user loaded, kept so they can be loaded
|
|
3
|
+
* again without finding the file.
|
|
4
|
+
*
|
|
5
|
+
* ### Why it holds the bundle, not a path or the files
|
|
6
|
+
*
|
|
7
|
+
* A plugin cannot re-read a file from disk. The UI gets a `File` at the moment
|
|
8
|
+
* the user picks it and nothing afterwards — no path, no handle, no second
|
|
9
|
+
* read. So a recents list that stored only a name would be a list you can look
|
|
10
|
+
* at and not click. The bundle travels with the entry: the document the core's
|
|
11
|
+
* `load` returned, every reference to another file already resolved, so it
|
|
12
|
+
* loads again with no file at all. The composition (what that load read, and
|
|
13
|
+
* how) travels beside it, so the screen can still say where it came from.
|
|
14
|
+
*
|
|
15
|
+
* Entries kept before the core loaded documents hold the document's text as
|
|
16
|
+
* `json`. Such an entry is a resolver document by construction; it is read as
|
|
17
|
+
* one, once, and the list is written back in the new shape.
|
|
18
|
+
*
|
|
19
|
+
* ### Why per user rather than per file
|
|
20
|
+
*
|
|
21
|
+
* `figma.clientStorage` is scoped to the person, not the document, and follows
|
|
22
|
+
* them into every file they open — the same argument that put the window size
|
|
23
|
+
* there. Recents are a property of someone's working habits. Putting them in
|
|
24
|
+
* the file instead would publish one person's in-progress token documents to
|
|
25
|
+
* everyone with access to it, and would make the list start empty in each new
|
|
26
|
+
* file, which is the opposite of useful.
|
|
27
|
+
*
|
|
28
|
+
* ### Size
|
|
29
|
+
*
|
|
30
|
+
* Figma documents no quota for `clientStorage`, and the typings state only that
|
|
31
|
+
* `setAsync` "will reject with an error message if storage failed" — so there
|
|
32
|
+
* is no number here to enforce, and inventing one would be a guess dressed as a
|
|
33
|
+
* constraint. Instead the write is attempted and, if it is refused, the oldest
|
|
34
|
+
* entry is dropped and it is attempted again, down to a single entry. A recents
|
|
35
|
+
* list is a convenience; it must never be the reason a load fails.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { ResolverDocument } from '@toghen/lib';
|
|
39
|
+
import type { Composition, RecentDocumentSummary } from '../../shared/protocol.js';
|
|
40
|
+
|
|
41
|
+
/** Key in `figma.clientStorage`, which is per user rather than per file. */
|
|
42
|
+
export const RECENTS_KEY = 'recentDocuments';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How many to keep.
|
|
46
|
+
*
|
|
47
|
+
* Enough to cover the systems someone moves between, short enough to read
|
|
48
|
+
* without scanning. It is a list you glance at, not one you search.
|
|
49
|
+
*/
|
|
50
|
+
export const MAX_RECENTS = 5;
|
|
51
|
+
|
|
52
|
+
export interface RecentDocument extends RecentDocumentSummary {
|
|
53
|
+
/** The bundle: `load`'s self-contained document, so the entry loads again without its files. */
|
|
54
|
+
document: ResolverDocument;
|
|
55
|
+
/** What the load that stored it read, and how. */
|
|
56
|
+
composition: Composition;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Reads the list, newest first.
|
|
61
|
+
*
|
|
62
|
+
* Never throws and never rejects. A corrupt or half-written blob reads as "no
|
|
63
|
+
* recents", which costs the user a trip to the file picker — the safe
|
|
64
|
+
* direction, and the one they were taking anyway before this existed.
|
|
65
|
+
*/
|
|
66
|
+
export async function loadRecents(): Promise<RecentDocument[]> {
|
|
67
|
+
try {
|
|
68
|
+
const stored: unknown = await figma.clientStorage.getAsync(RECENTS_KEY);
|
|
69
|
+
if (!Array.isArray(stored)) return [];
|
|
70
|
+
|
|
71
|
+
const recents = stored.map(fromOldShape).filter(isRecentDocument).slice(0, MAX_RECENTS);
|
|
72
|
+
if (stored.some(isOldShape)) return await writeAsFarAsItFits(recents);
|
|
73
|
+
return recents;
|
|
74
|
+
} catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Puts an entry at the top of the list and writes it back.
|
|
81
|
+
*
|
|
82
|
+
* Entries are identified by {@link RecentDocument.key}, so loading an edited
|
|
83
|
+
* document replaces its entry and moves it to the front rather than growing a
|
|
84
|
+
* second row that differs only by timestamp. Editing a document and loading it
|
|
85
|
+
* again is a normal working loop, not a new system.
|
|
86
|
+
*
|
|
87
|
+
* @returns the list as stored, so the caller can report it without re-reading.
|
|
88
|
+
*/
|
|
89
|
+
export async function rememberRecent(entry: RecentDocument): Promise<RecentDocument[]> {
|
|
90
|
+
const existing = await loadRecents();
|
|
91
|
+
const next = [entry, ...existing.filter((item) => item.key !== entry.key)].slice(0, MAX_RECENTS);
|
|
92
|
+
|
|
93
|
+
return writeAsFarAsItFits(next);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Drops one entry, by key. */
|
|
97
|
+
export async function forgetRecent(key: string): Promise<RecentDocument[]> {
|
|
98
|
+
const next = (await loadRecents()).filter((item) => item.key !== key);
|
|
99
|
+
return writeAsFarAsItFits(next);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What the UI needs to draw the list — everything except the document itself. */
|
|
103
|
+
export function summarize(recents: readonly RecentDocument[]): RecentDocumentSummary[] {
|
|
104
|
+
// The documents are deliberately left behind. The UI never needs them, and
|
|
105
|
+
// posting five of them across the iframe boundary on every load is work
|
|
106
|
+
// nobody asked for.
|
|
107
|
+
return recents.map(({ document: _document, composition: _composition, ...summary }) => summary);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Writes the list, shedding the oldest entries until the write is accepted.
|
|
112
|
+
*
|
|
113
|
+
* `setAsync` rejects when storage fails and names no budget beforehand, so the
|
|
114
|
+
* only way to find the limit is to reach it. Shedding from the end drops the
|
|
115
|
+
* least recently used first, which is the entry a user is least likely to miss.
|
|
116
|
+
*/
|
|
117
|
+
async function writeAsFarAsItFits(entries: RecentDocument[]): Promise<RecentDocument[]> {
|
|
118
|
+
for (let count = entries.length; count > 0; count -= 1) {
|
|
119
|
+
const attempt = entries.slice(0, count);
|
|
120
|
+
try {
|
|
121
|
+
await figma.clientStorage.setAsync(RECENTS_KEY, attempt);
|
|
122
|
+
return attempt;
|
|
123
|
+
} catch {
|
|
124
|
+
// Try again with one fewer.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await figma.clientStorage.deleteAsync(RECENTS_KEY);
|
|
130
|
+
} catch {
|
|
131
|
+
// Nothing further to try, and nothing here is worth failing a load over.
|
|
132
|
+
}
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isRecentDocument(value: unknown): value is RecentDocument {
|
|
137
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
138
|
+
|
|
139
|
+
const entry = value as Partial<RecentDocument>;
|
|
140
|
+
return (
|
|
141
|
+
typeof entry.key === 'string' &&
|
|
142
|
+
typeof entry.name === 'string' &&
|
|
143
|
+
typeof entry.document === 'object' &&
|
|
144
|
+
entry.document !== null &&
|
|
145
|
+
typeof entry.composition === 'object' &&
|
|
146
|
+
entry.composition !== null &&
|
|
147
|
+
typeof entry.loadedAt === 'string'
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** An entry kept before the core loaded documents: the document's text as `json`, and no composition. */
|
|
152
|
+
const isOldShape = (value: unknown): boolean =>
|
|
153
|
+
typeof value === 'object' && value !== null && typeof (value as { json?: unknown }).json === 'string';
|
|
154
|
+
|
|
155
|
+
/** An old entry read as the resolver document it always was; any other value as it is. One that does not parse is dropped by the filter. */
|
|
156
|
+
function fromOldShape(value: unknown): unknown {
|
|
157
|
+
if (!isOldShape(value)) return value;
|
|
158
|
+
const { json, ...entry } = value as RecentDocumentSummary & { json: string };
|
|
159
|
+
try {
|
|
160
|
+
const document = JSON.parse(json) as ResolverDocument;
|
|
161
|
+
const composition: Composition = { form: 'resolver', entry: entry.fileName ?? entry.key, reads: [] };
|
|
162
|
+
return { ...entry, document, composition };
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { InvalidTokenError, referenceTarget, type DesignSystem, type Resolver } from '@toghen/lib';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A resolver document resolved into its sum: a single multi-mode `DesignSystem`.
|
|
5
|
+
*
|
|
6
|
+
* @see resolveSumModes
|
|
7
|
+
*/
|
|
8
|
+
export interface MultiModeResolution {
|
|
9
|
+
/** One collection, one mode per context of each modifier, and the defaults. */
|
|
10
|
+
ds: DesignSystem;
|
|
11
|
+
/** Name of the collection inside `ds` — the leading segment of every token path. */
|
|
12
|
+
dsCollectionName: string;
|
|
13
|
+
/** Mode names: the defaults first, then each other context on its own; the order Figma modes are created in. */
|
|
14
|
+
modeNames: string[];
|
|
15
|
+
/** The mode of the document's default context selections: the core's `getDefaultMode()`. */
|
|
16
|
+
defaultMode: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The Figma collection a document's variables belong in.
|
|
21
|
+
*
|
|
22
|
+
* Shared rather than repeated: the sync plan and the Inspector must look at the
|
|
23
|
+
* *same* collection, or the one describing what is and the one proposing what to
|
|
24
|
+
* do would be talking about two different sets of variables — and neither would
|
|
25
|
+
* have any way to notice.
|
|
26
|
+
*/
|
|
27
|
+
export function figmaCollectionNameFor(resolver: Resolver, preferred?: string): string {
|
|
28
|
+
return preferred?.trim() || resolver.name || 'resolved';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The most modes Figma lets a collection hold, for every file. Figma's limit depends on the plan of
|
|
33
|
+
* the file (4, 10, 20 or 40); Toghen does not read the plan and takes no other number, so what a
|
|
34
|
+
* design system syncs does not depend on who pays for the file.
|
|
35
|
+
*/
|
|
36
|
+
export const MAX_MODES = 10;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The contexts of a modifier that Figma gets, the default first: all of them, or the ones its
|
|
40
|
+
* `$extensions["com.toghen"].figma.include` lists, in the document's order whatever order the list
|
|
41
|
+
* is written in. CSS reads nothing from this key; the contexts it leaves out are still written there.
|
|
42
|
+
*
|
|
43
|
+
* @throws {Error} Naming the modifier, when `include` is not a list of names, names a context the
|
|
44
|
+
* modifier does not have, or leaves out the default context.
|
|
45
|
+
*/
|
|
46
|
+
export function figmaContexts(modifier: {
|
|
47
|
+
name: string;
|
|
48
|
+
default?: string;
|
|
49
|
+
contexts: readonly string[];
|
|
50
|
+
$extensions?: Record<string, any>;
|
|
51
|
+
}): string[] {
|
|
52
|
+
// eslint-disable-line @typescript-eslint/no-explicit-any
|
|
53
|
+
// `resolveSumModes` refuses a modifier with no default before any caller reaches here.
|
|
54
|
+
const first = modifier.default!;
|
|
55
|
+
const all = [first, ...modifier.contexts.filter((context) => context !== first)];
|
|
56
|
+
const include: unknown = modifier.$extensions?.['com.toghen']?.figma?.include;
|
|
57
|
+
if (include === undefined) return all;
|
|
58
|
+
|
|
59
|
+
const where = `Modifier "${modifier.name}"`;
|
|
60
|
+
if (!Array.isArray(include) || include.some((name) => typeof name !== 'string')) {
|
|
61
|
+
throw new Error(`${where}: figma.include must be a list of context names.`);
|
|
62
|
+
}
|
|
63
|
+
const unknown = include.filter((name) => !all.includes(name));
|
|
64
|
+
if (unknown.length > 0) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`${where} lists ${unknown.map((name) => `"${name}"`).join(', ')} in figma.include, which ` +
|
|
67
|
+
`${unknown.length === 1 ? 'is not a context' : 'are not contexts'} of it (it has ${all.map((name) => `"${name}"`).join(', ')}).`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (!include.includes(first)) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`${where}: figma.include must list its default context "${first}". Figma's default mode is the first ` +
|
|
73
|
+
`one of the collection and is fixed when the collection is made, so the default cannot be left out.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return all.filter((context) => include.includes(context));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One mode's view of the document: every token path and its resolved value. */
|
|
80
|
+
export interface ModeSnapshot {
|
|
81
|
+
modeName: string;
|
|
82
|
+
pathToToken: Map<string, SnapshotToken>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface SnapshotToken {
|
|
86
|
+
value: unknown;
|
|
87
|
+
type: string;
|
|
88
|
+
/** The core's reason, where it cannot compute the value for the type (then `value` is undefined): the token is one skip. */
|
|
89
|
+
refused?: string;
|
|
90
|
+
/**
|
|
91
|
+
* The token this one is, when its whole value is one reference: the target's
|
|
92
|
+
* lookup path, first hop. Syntactic only; whether Figma can express it as an
|
|
93
|
+
* alias is decided where types are known, in `correspondence.ts`.
|
|
94
|
+
*/
|
|
95
|
+
aliasOf?: string;
|
|
96
|
+
/**
|
|
97
|
+
* What each member of a composite refers to, when the member is one whole
|
|
98
|
+
* reference: member address (`'fontSize'`, `'0.color'`) → target lookup path.
|
|
99
|
+
* Absent when there are none. Whether Figma can bind it is decided where
|
|
100
|
+
* fields are known, in `styleSyncEngine.ts`.
|
|
101
|
+
*/
|
|
102
|
+
memberOf?: Record<string, string>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolves a document's **sum**: the defaults, and each context of each
|
|
107
|
+
* modifier on its own, in one `DesignSystem`.
|
|
108
|
+
*
|
|
109
|
+
* It has a mode per context, not per combination: what a Figma file with a
|
|
110
|
+
* collection per modifier holds, and the only system the plugin builds, since
|
|
111
|
+
* the product of a large document does not finish. `defaultMode` is the core's
|
|
112
|
+
* answer, `getDefaultMode()`, which the sum places first in `modeNames`.
|
|
113
|
+
*
|
|
114
|
+
* Lib builds the sum with `{}` as its base, which throws for a modifier that
|
|
115
|
+
* declares no `default` (a legal document). The sum needs one for each, so this
|
|
116
|
+
* throws, naming each such modifier; there is no fallback to the product.
|
|
117
|
+
*
|
|
118
|
+
* Built once per document: it is the slow part of a large load.
|
|
119
|
+
*/
|
|
120
|
+
export function resolveSumModes(resolver: Resolver): MultiModeResolution {
|
|
121
|
+
const cached = sums.get(resolver);
|
|
122
|
+
if (cached) return cached;
|
|
123
|
+
|
|
124
|
+
const undeclared = resolver
|
|
125
|
+
.getModifiers()
|
|
126
|
+
.filter((modifier) => modifier.default === undefined)
|
|
127
|
+
.map((modifier) => modifier.name);
|
|
128
|
+
if (undeclared.length > 0) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
undeclared.map((name) => `Modifier "${name}" declares no default context.`).join(' ') +
|
|
131
|
+
` The sum of the contexts needs a default for each modifier: its Figma collection starts with the default ` +
|
|
132
|
+
`context, because Figma's default mode is fixed when the collection is made. Declare a "default" ` +
|
|
133
|
+
`for ${undeclared.length === 1 ? 'it' : 'each'} and load the document again.`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const ds = resolver.resolveContextsToDesignSystem();
|
|
138
|
+
const dsCollectionName = ds.getCollectionNames()[0];
|
|
139
|
+
const collection = ds.getCollection(dsCollectionName);
|
|
140
|
+
const modeNames = collection?.getModes() ?? [];
|
|
141
|
+
|
|
142
|
+
const resolution = { ds, dsCollectionName, modeNames, defaultMode: collection?.getDefaultMode() ?? '' };
|
|
143
|
+
sums.set(resolver, resolution);
|
|
144
|
+
return resolution;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The sums already built, by document. A loaded document does not change. */
|
|
148
|
+
const sums = new WeakMap<Resolver, MultiModeResolution>();
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Reads every mode of a resolved system into a flat path → value map.
|
|
152
|
+
*
|
|
153
|
+
* References are expanded per mode, so a semantic token pointing at different
|
|
154
|
+
* primitives in `light` and `dark` yields the two different concrete values.
|
|
155
|
+
* Where a mode's whole value is one reference, `aliasOf` keeps what resolution
|
|
156
|
+
* erased, and `memberOf` does the same for a composite's members; the library
|
|
157
|
+
* says so, since the plugin never reads `{...}` itself.
|
|
158
|
+
*
|
|
159
|
+
* @param resolution - Output of {@link resolveSumModes}
|
|
160
|
+
* @returns One snapshot per mode, in `modeNames` order
|
|
161
|
+
*/
|
|
162
|
+
export function snapshotModes(resolution: MultiModeResolution): ModeSnapshot[] {
|
|
163
|
+
const { ds, dsCollectionName, modeNames } = resolution;
|
|
164
|
+
const collection = ds.getCollection(dsCollectionName);
|
|
165
|
+
if (!collection) return [];
|
|
166
|
+
|
|
167
|
+
return modeNames.map((modeName) => {
|
|
168
|
+
const pathToToken = new Map<string, SnapshotToken>();
|
|
169
|
+
|
|
170
|
+
for (const tokenPath of collection.getAllPaths(modeName)) {
|
|
171
|
+
const fullPath = `${dsCollectionName}.${tokenPath}`;
|
|
172
|
+
const token = readToken(ds, fullPath, modeName);
|
|
173
|
+
if (!token) continue;
|
|
174
|
+
|
|
175
|
+
const references = ds.referencesOf(fullPath, modeName);
|
|
176
|
+
const whole = references.length === 1 && references[0].at === '' && !references[0].interpolated;
|
|
177
|
+
const memberOf: Record<string, string> = {};
|
|
178
|
+
for (const ref of references) {
|
|
179
|
+
if (ref.at !== '' && !ref.interpolated) memberOf[ref.at] = ref.to;
|
|
180
|
+
}
|
|
181
|
+
pathToToken.set(fullPath, {
|
|
182
|
+
value: token.$value,
|
|
183
|
+
type: token.$type,
|
|
184
|
+
...(token.refused === undefined ? {} : { refused: token.refused }),
|
|
185
|
+
...(whole ? { aliasOf: references[0].to } : {}),
|
|
186
|
+
...(Object.keys(memberOf).length > 0 ? { memberOf } : {}),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { modeName, pathToToken };
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* A token as the core resolves it in one mode, or null when it is not there. Where the core refuses to compute the
|
|
196
|
+
* value for the type (a typed `dimension` that resolves to `8px 13px`), its type and the core's reason instead, so
|
|
197
|
+
* that one token is one skip and not the stop of a plan. Any other failure is thrown.
|
|
198
|
+
*
|
|
199
|
+
* The core gives a token's type only with its value, so where it refuses the value the type is read here as the core
|
|
200
|
+
* reads it (W3C DTCG §5.2.2): the token's own, else the type of the token its whole value refers to, else its
|
|
201
|
+
* nearest group's.
|
|
202
|
+
*/
|
|
203
|
+
export function readToken(ds: DesignSystem, path: string, mode: string): { $type: string; $value?: unknown; refused?: string } | null {
|
|
204
|
+
try {
|
|
205
|
+
return ds.resolveToken(path, mode);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (!(error instanceof InvalidTokenError)) throw error;
|
|
208
|
+
return { $type: typeOfRefused(ds, path, mode), refused: error.message };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function typeOfRefused(ds: DesignSystem, path: string, mode: string): string {
|
|
213
|
+
const lookup = ds.lookupPath(path);
|
|
214
|
+
const name = lookup.slice(0, lookup.indexOf('.'));
|
|
215
|
+
const tokenPath = lookup.slice(name.length + 1);
|
|
216
|
+
const collection = ds.getCollection(name);
|
|
217
|
+
const raw = collection?.getToken(tokenPath, mode);
|
|
218
|
+
if (!collection || !raw) return '';
|
|
219
|
+
const target = referenceTarget(raw.$value);
|
|
220
|
+
const through = target === undefined ? '' : (readToken(ds, `${name}.${target}`, mode)?.$type ?? '');
|
|
221
|
+
if (raw.$type || through) return raw.$type || through;
|
|
222
|
+
const segments = tokenPath.split('.');
|
|
223
|
+
for (let i = segments.length - 1; i > 0; i -= 1) {
|
|
224
|
+
const type = collection.getGroupAttribute(segments.slice(0, i).join('.'), '$type', mode);
|
|
225
|
+
if (type !== null) return type as string;
|
|
226
|
+
}
|
|
227
|
+
return '';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Every token path of the sum that has a value in at least one of `modes` (all of them when none are given), with
|
|
232
|
+
* the modes it has one in. Reference paths, in order of first appearance, so a plan is stable between runs; the
|
|
233
|
+
* modes in `modeNames` order. Planning from one mode's paths is what made mode-only tokens vanish without a warning.
|
|
234
|
+
*
|
|
235
|
+
* The core walks every mode only privately (`getAllPaths()` takes one), and this adapter is its one consumer, so
|
|
236
|
+
* the walk is here, once: the sum, the token graph, the document's counts and the component files all call it.
|
|
237
|
+
*/
|
|
238
|
+
export function pathsInAnyMode(resolution: MultiModeResolution, modes: readonly string[] = resolution.modeNames): Map<string, string[]> {
|
|
239
|
+
const collection = resolution.ds.getCollection(resolution.dsCollectionName);
|
|
240
|
+
const found = new Map<string, string[]>();
|
|
241
|
+
for (const mode of modes) {
|
|
242
|
+
for (const path of collection?.getAllPaths(mode) ?? []) (found.get(path) ?? found.set(path, []).get(path)!).push(mode);
|
|
243
|
+
}
|
|
244
|
+
return found;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** A reference to a token: who refers (a lookup path), and where in its value (`''` for the whole value, `fontSize` for a member). */
|
|
248
|
+
export interface Referrer {
|
|
249
|
+
from: string;
|
|
250
|
+
at: string;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Who refers to each token, in the modes `pathsInAnyMode` gave: target lookup path → each referrer, once per member.
|
|
255
|
+
* The core answers what one token refers to (`referencesOf()`); the reverse has one consumer, this adapter, so it
|
|
256
|
+
* is built here, once, for the sum's purposes and the token graph alike.
|
|
257
|
+
*/
|
|
258
|
+
export function referrersOf(resolution: MultiModeResolution, paths: ReadonlyMap<string, readonly string[]>): Map<string, Referrer[]> {
|
|
259
|
+
const { ds, dsCollectionName } = resolution;
|
|
260
|
+
const referrers = new Map<string, Referrer[]>();
|
|
261
|
+
for (const [path, modes] of paths) {
|
|
262
|
+
const from = `${dsCollectionName}.${path}`;
|
|
263
|
+
for (const mode of modes) {
|
|
264
|
+
for (const { at, to } of ds.referencesOf(from, mode)) {
|
|
265
|
+
const into = referrers.get(to) ?? referrers.set(to, []).get(to)!;
|
|
266
|
+
if (!into.some((known) => known.from === from && known.at === at)) into.push({ from, at });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return referrers;
|
|
271
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The root record of an older build, turned into stamps once (decisions, *Ruled: the stamp, one receipt per object,
|
|
3
|
+
* for every target*).
|
|
4
|
+
*
|
|
5
|
+
* The record named, per token path, the variable or style written for it and a hash of the token's value per mode
|
|
6
|
+
* name. A stamp hashes what Figma reports right after a write, and what Figma holds now is no proof of that: a hand
|
|
7
|
+
* edit made before the upgrade, which the old build kept while the document stood still, would read as written and be
|
|
8
|
+
* overwritten. So a field the record says was written is taken as written only where Figma holds the document's value,
|
|
9
|
+
* a variable mode by mode and a style field by field; any other is stamped `UNPROVEN`, a hash no report has, and reads
|
|
10
|
+
* as drift, kept and reported. Where the document moved since the write, the record's hash cannot say what
|
|
11
|
+
* Figma held then, so a field Figma does not agree on is drift too, hand edit or not; `--overwrite-drift` writes it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Resolver } from '@toghen/lib';
|
|
15
|
+
import type { TokenTypeConversionMap } from '../../shared/tokenTypeMap.js';
|
|
16
|
+
import { getStamp, hashOf, receiptOf, setStamp, UNPROVEN, type Stamp, type Stamped } from '../components/stamp.js';
|
|
17
|
+
import { holds, StyleSyncEngine, wantedOf } from '../engines/styleSyncEngine.js';
|
|
18
|
+
import { setState } from '../runtimeState.js';
|
|
19
|
+
import { VariableSyncEngine } from '../engines/variableSyncEngine.js';
|
|
20
|
+
import { figmaCollectionNameFor } from './resolverModes.js';
|
|
21
|
+
import { clearSharded, isPlainObject, readSharded } from './shardedData.js';
|
|
22
|
+
import { reportedScopes, reportOf, type StyleKind } from './tokenStamps.js';
|
|
23
|
+
|
|
24
|
+
/** Where older builds kept the root record. */
|
|
25
|
+
const RECORD_KEY = 'extensions';
|
|
26
|
+
|
|
27
|
+
/** Puts a stamp where the plans read it, before it is written: in `unwrittenStamps`, by the object's id. */
|
|
28
|
+
type Put = (object: Stamped, stamp: Stamp) => void;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Turns the root record into stamps, then deletes it with its shards. One way only. The scopes the record kept are what
|
|
32
|
+
* Toghen wrote, so they are stamped as such. An object already stamped is left as it is. The stamps are worked out in
|
|
33
|
+
* memory, where the plans that prove them read them as the objects' own, and written at the end. With `dryRun` nothing
|
|
34
|
+
* is written: the stamps stay in memory for the dry run's plans, and the record stays for the first real load. Never
|
|
35
|
+
* throws: a record that cannot be read names nothing and is deleted, and one whose stamps cannot be written is kept for
|
|
36
|
+
* the next try.
|
|
37
|
+
*/
|
|
38
|
+
export async function migrateRootRecord(
|
|
39
|
+
resolver: Resolver,
|
|
40
|
+
conversionMap: TokenTypeConversionMap,
|
|
41
|
+
{ dryRun = false }: { dryRun?: boolean } = {},
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
const unwritten = new Map<string, Stamp>();
|
|
44
|
+
const objects: Stamped[] = [];
|
|
45
|
+
const put: Put = (object, stamp) => {
|
|
46
|
+
if (!unwritten.has(object.id)) objects.push(object);
|
|
47
|
+
unwritten.set(object.id, { ...stamp, id: object.id });
|
|
48
|
+
};
|
|
49
|
+
setState({ unwrittenStamps: unwritten });
|
|
50
|
+
try {
|
|
51
|
+
let record: unknown;
|
|
52
|
+
try {
|
|
53
|
+
const payload = readSharded(RECORD_KEY);
|
|
54
|
+
if (!payload) return;
|
|
55
|
+
record = JSON.parse(payload);
|
|
56
|
+
} catch (_unreadable) {
|
|
57
|
+
record = undefined;
|
|
58
|
+
}
|
|
59
|
+
const variables = new Set<string>();
|
|
60
|
+
const styles = new Map<string, BaseStyle>();
|
|
61
|
+
for (const [token, entry] of Object.entries(isPlainObject(record) ? record : {})) {
|
|
62
|
+
if (!isPlainObject(entry)) continue;
|
|
63
|
+
const variable = typeof entry.variableId === 'string' ? await figma.variables.getVariableByIdAsync(entry.variableId) : null;
|
|
64
|
+
if (variable && !getStamp(variable)) {
|
|
65
|
+
const collection = await figma.variables.getVariableCollectionByIdAsync(variable.variableCollectionId);
|
|
66
|
+
const hashes = isPlainObject(entry.lastSyncedHashes) ? entry.lastSyncedHashes : {};
|
|
67
|
+
const receipt: Record<string, string> = {};
|
|
68
|
+
for (const mode of collection?.modes ?? []) {
|
|
69
|
+
if (hashes[mode.name] !== undefined && variable.valuesByMode[mode.modeId] !== undefined) receipt[mode.modeId] = UNPROVEN;
|
|
70
|
+
}
|
|
71
|
+
if (Array.isArray(entry.lastSyncedScopes))
|
|
72
|
+
Object.assign(receipt, receiptOf({ scopes: reportedScopes(entry.lastSyncedScopes as VariableScope[]) }));
|
|
73
|
+
put(variable, { is: { token }, receipt });
|
|
74
|
+
variables.add(variable.id);
|
|
75
|
+
}
|
|
76
|
+
const style = typeof entry.styleId === 'string' ? await figma.getStyleByIdAsync(entry.styleId) : null;
|
|
77
|
+
if (style && !getStamp(style)) {
|
|
78
|
+
// Taken, for now, as holding what was written: `proveAgreeing` asks the style plan which fields it does.
|
|
79
|
+
put(style, { is: { token }, receipt: receiptOf(reportOf(style)) });
|
|
80
|
+
styles.set(token, style);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
await proveAgreeing(resolver, conversionMap, variables, styles, put);
|
|
84
|
+
if (dryRun) return;
|
|
85
|
+
for (const object of objects) setStamp(object, unwritten.get(object.id)!);
|
|
86
|
+
clearSharded(RECORD_KEY);
|
|
87
|
+
} catch (_unwritable) {
|
|
88
|
+
// Figma refused a stamp: the record is kept, and the next load tries again. A dry run plans as if it named nothing.
|
|
89
|
+
unwritten.clear();
|
|
90
|
+
} finally {
|
|
91
|
+
if (!dryRun) setState({ unwrittenStamps: null });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Takes as written each field of these variables and styles that Figma holds the document's value in, and stamps
|
|
97
|
+
* every other `UNPROVEN`. A variable's modes start unproven, so its plan writes one only where Figma agrees with the
|
|
98
|
+
* document. A style starts as holding what was written, so its plan says, of one Figma agrees on, unchanged, and of
|
|
99
|
+
* any other, what it would write: the fields that differ from that are the unproven ones. A plan that stops, or a
|
|
100
|
+
* token it refuses, leaves every field unproven.
|
|
101
|
+
*/
|
|
102
|
+
async function proveAgreeing(
|
|
103
|
+
resolver: Resolver,
|
|
104
|
+
conversionMap: TokenTypeConversionMap,
|
|
105
|
+
variables: Set<string>,
|
|
106
|
+
styles: Map<string, BaseStyle>,
|
|
107
|
+
put: Put,
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
const collectionName = figmaCollectionNameFor(resolver);
|
|
110
|
+
if (variables.size > 0) {
|
|
111
|
+
for (const op of (await new VariableSyncEngine().buildPlan(resolver, collectionName, conversionMap)).operations) {
|
|
112
|
+
if (op.kind !== 'SET_VARIABLE_VALUE' || !variables.has(op.variableId)) continue;
|
|
113
|
+
const variable = await figma.variables.getVariableByIdAsync(op.variableId);
|
|
114
|
+
const stamp = variable ? getStamp(variable) : undefined;
|
|
115
|
+
if (!variable || !stamp || stamp.receipt[op.modeId] !== UNPROVEN) continue;
|
|
116
|
+
put(variable, { ...stamp, receipt: { ...stamp.receipt, ...receiptOf({ [op.modeId]: variable.valuesByMode[op.modeId] }) } });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (styles.size > 0) {
|
|
120
|
+
const operations = (await new StyleSyncEngine().buildPlan(resolver, conversionMap, collectionName)).operations;
|
|
121
|
+
for (const [token, style] of styles) {
|
|
122
|
+
const op = operations.find(
|
|
123
|
+
(o) => (o.kind === 'UPDATE_STYLE' && o.styleId === style.id) || (o.kind === 'SKIP' && o.reason === 'unchanged' && o.path === token),
|
|
124
|
+
);
|
|
125
|
+
// ponytail: a field is compared with what this machine would write only, so one that holds what a machine with
|
|
126
|
+
// the stack's first family wrote stays unproven here, beside a field edited by hand; the plan's own check takes both.
|
|
127
|
+
const wanted =
|
|
128
|
+
op?.kind === 'UPDATE_STYLE'
|
|
129
|
+
? wantedOf(style.type as StyleKind, op.props as unknown as Record<string, unknown>, op.bindings ?? [])
|
|
130
|
+
: {};
|
|
131
|
+
const receipt: Record<string, string> = {};
|
|
132
|
+
for (const [field, value] of Object.entries(reportOf(style)))
|
|
133
|
+
receipt[field] = op && (!(field in wanted) || holds(value, wanted[field])) ? hashOf(value) : UNPROVEN;
|
|
134
|
+
put(style, { is: { token }, receipt });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|