@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,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component files and blueprints kept in this Figma file, and building from them.
|
|
3
|
+
*
|
|
4
|
+
* A kept file is read against the loaded design system and built through the
|
|
5
|
+
* same entry `RUN_COMPONENT` uses (`components/file.ts`): its text and its name.
|
|
6
|
+
* A blueprint (`.blueprint.kdl`) is kept under its page's name once it reads; it
|
|
7
|
+
* is not built as a whole yet. Its needs are looked up by the locator in a dry
|
|
8
|
+
* pass (`LOOK_UP_NEEDS`), and a relink is planned by a dry build of every kept
|
|
9
|
+
* file (`PLAN_RELINK`); neither imports anything.
|
|
10
|
+
* See `reference.md`, *Templates kept in the file*.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Command, NeedsPayload, Response, TemplateSummary, TemplatesPayload } from '../../shared/protocol.js';
|
|
14
|
+
import type { Operation } from '../../shared/operations.js';
|
|
15
|
+
import { blueprintNeeds, isBlueprintFile } from '../../shared/components/index.js';
|
|
16
|
+
import type { Property, RunOptions, Step } from '../../shared/components/index.js';
|
|
17
|
+
import { beginRun, findStrays, finishRun, strayLine } from '../components/endOfRun.js';
|
|
18
|
+
import { ComponentRunError } from '../components/errors.js';
|
|
19
|
+
import {
|
|
20
|
+
describe,
|
|
21
|
+
desiredOf,
|
|
22
|
+
readBlueprintText,
|
|
23
|
+
readComponentText,
|
|
24
|
+
readErrors,
|
|
25
|
+
runComponentFile,
|
|
26
|
+
summarize as summarizeStep,
|
|
27
|
+
} from '../components/file.js';
|
|
28
|
+
import { declaredLibraries, locateComponent, localComponents } from '../components/resolve.js';
|
|
29
|
+
import { run } from '../components/run.js';
|
|
30
|
+
import { getState } from '../runtimeState.js';
|
|
31
|
+
import { loadLock } from '../utils/lockStore.js';
|
|
32
|
+
import { sortNodes, stampedAs, stampOn } from '../components/stamp.js';
|
|
33
|
+
import { findInEveryPage } from '../utils/pageRoots.js';
|
|
34
|
+
import { keepTemplate, loadTemplates, saveTemplates, type KeptTemplate } from '../utils/templateStore.js';
|
|
35
|
+
|
|
36
|
+
type Payload<T extends Command['type']> = Extract<Command, { type: T }>['payload'];
|
|
37
|
+
|
|
38
|
+
export async function GET_TEMPLATES(): Promise<void> {
|
|
39
|
+
postToUi({ type: 'TEMPLATES', payload: await summarize(loadTemplates()) });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function KEEP_TEMPLATE(payload: Payload<'KEEP_TEMPLATE'>): Promise<void> {
|
|
43
|
+
const blueprint = isBlueprintFile(payload.fileName);
|
|
44
|
+
const name = blueprint ? readBlueprintToKeep(payload) : readComponentToKeep(payload);
|
|
45
|
+
if (name === undefined) return;
|
|
46
|
+
// A row is found by its name: a blueprint whose page is named as a kept component would take that component's place.
|
|
47
|
+
const other = loadTemplates().find(
|
|
48
|
+
(kept) => kept.name === name && isBlueprintFile(kept.fileName) !== blueprint && kept.name !== payload.replaces,
|
|
49
|
+
);
|
|
50
|
+
if (other) {
|
|
51
|
+
postToUi({
|
|
52
|
+
type: 'ERROR',
|
|
53
|
+
payload: {
|
|
54
|
+
message: `${payload.fileName} was not kept: "${name}" is already kept from ${other.fileName}, and a blueprint and a component cannot share a name here. Forget that one first.`,
|
|
55
|
+
code: 'INVALID_TEMPLATE',
|
|
56
|
+
commandType: 'KEEP_TEMPLATE',
|
|
57
|
+
problems: [],
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const kept: KeptTemplate = { name, fileName: payload.fileName, text: payload.text, kept: new Date().toISOString() };
|
|
64
|
+
const saved = saveTemplates(keepTemplate(loadTemplates(), kept, payload.replaces));
|
|
65
|
+
if (!saved.ok) {
|
|
66
|
+
postToUi({ type: 'ERROR', payload: { message: saved.reason, code: 'FIGMA_API_ERROR', commandType: 'KEEP_TEMPLATE' } });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
await GET_TEMPLATES();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A blueprint is kept under its page's name when it reads; else every problem is said, and nothing is kept. */
|
|
73
|
+
function readBlueprintToKeep(payload: Payload<'KEEP_TEMPLATE'>): string | undefined {
|
|
74
|
+
const read = readBlueprintText(payload.text);
|
|
75
|
+
const problems = read.problems.filter((problem) => problem.severity === 'error').map((problem) => describe(payload.fileName, problem));
|
|
76
|
+
if (problems.length === 0 && read.page !== undefined) return read.page;
|
|
77
|
+
postToUi({
|
|
78
|
+
type: 'ERROR',
|
|
79
|
+
payload: {
|
|
80
|
+
message: `${payload.fileName} does not read, so it was not kept: ${problems.length === 1 ? 'one problem' : `${problems.length} problems`}`,
|
|
81
|
+
code: 'INVALID_TEMPLATE',
|
|
82
|
+
commandType: 'KEEP_TEMPLATE',
|
|
83
|
+
problems,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A component file is kept under its component's name when it reads; else every problem is said, and nothing is kept. */
|
|
90
|
+
function readComponentToKeep(payload: Payload<'KEEP_TEMPLATE'>): string | undefined {
|
|
91
|
+
const read = readComponentText(payload.text);
|
|
92
|
+
const problems = readErrors(read, payload.fileName);
|
|
93
|
+
if (read.artboard) {
|
|
94
|
+
// An artboard reads its inputs from the command line; nothing on the screen supplies them, so it is built from there.
|
|
95
|
+
postToUi({
|
|
96
|
+
type: 'ERROR',
|
|
97
|
+
payload: {
|
|
98
|
+
message: `${payload.fileName} is an artboard file, which needs inputs only the command line gives, so it was not kept`,
|
|
99
|
+
code: 'INVALID_TEMPLATE',
|
|
100
|
+
commandType: 'KEEP_TEMPLATE',
|
|
101
|
+
problems: [],
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
if (!read.component || problems.length > 0) {
|
|
107
|
+
postToUi({
|
|
108
|
+
type: 'ERROR',
|
|
109
|
+
payload: {
|
|
110
|
+
message: read.component
|
|
111
|
+
? `${payload.fileName} does not read, so it was not kept: ${problems.length === 1 ? 'one problem' : `${problems.length} problems`}`
|
|
112
|
+
: `${payload.fileName} holds no component, so it was not kept`,
|
|
113
|
+
code: 'INVALID_TEMPLATE',
|
|
114
|
+
commandType: 'KEEP_TEMPLATE',
|
|
115
|
+
problems,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
return read.component.name;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function FORGET_TEMPLATE(payload: Payload<'FORGET_TEMPLATE'>): Promise<void> {
|
|
124
|
+
const saved = saveTemplates(loadTemplates().filter((template) => template.name !== payload.name));
|
|
125
|
+
if (!saved.ok) {
|
|
126
|
+
postToUi({ type: 'ERROR', payload: { message: saved.reason, code: 'FIGMA_API_ERROR', commandType: 'FORGET_TEMPLATE' } });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
await GET_TEMPLATES();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function PLAN_BUILD(payload: Payload<'PLAN_BUILD'>): Promise<void> {
|
|
133
|
+
await build(payload.name, { dryRun: true }, 'PLAN_BUILD');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function EXECUTE_BUILD(payload: Payload<'EXECUTE_BUILD'>): Promise<void> {
|
|
137
|
+
if (await build(payload.name, { allowBreaking: payload.allowBreaking === true }, 'EXECUTE_BUILD')) {
|
|
138
|
+
// A build changes which sets exist, and so what the pane says about each template.
|
|
139
|
+
await GET_TEMPLATES();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Runs the engine on one kept template. True when it ran to the end. */
|
|
144
|
+
async function build(name: string, options: { dryRun?: boolean; allowBreaking?: boolean }, commandType: string): Promise<boolean> {
|
|
145
|
+
const template = loadTemplates().find((kept) => kept.name === name);
|
|
146
|
+
if (!template) {
|
|
147
|
+
postToUi({
|
|
148
|
+
type: 'ERROR',
|
|
149
|
+
payload: { message: `No template named "${name}" is kept in this file.`, code: 'INVALID_TEMPLATE', commandType },
|
|
150
|
+
});
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
if (isBlueprintFile(template.fileName)) {
|
|
154
|
+
postToUi({
|
|
155
|
+
type: 'ERROR',
|
|
156
|
+
payload: {
|
|
157
|
+
message: `"${name}" is a blueprint, and a blueprint is not built as a whole yet: build each of its components from its own file.`,
|
|
158
|
+
code: 'INVALID_TEMPLATE',
|
|
159
|
+
commandType,
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
// A template run from the screen is a run of its own: strays are found before anything is written, and the end of the run follows the build.
|
|
167
|
+
beginRun();
|
|
168
|
+
const strays = await findStrays();
|
|
169
|
+
const lines = strays.map(strayLine);
|
|
170
|
+
if (lines.length > 0 && !options.dryRun && !options.allowBreaking) {
|
|
171
|
+
throw new ComponentRunError(
|
|
172
|
+
`will not build "${name}": ${lines.length === 1 ? 'a member is' : `${lines.length} members are`} outside any set, and nothing was written; pass allowBreaking to remove ${lines.length === 1 ? 'it' : 'them'}`,
|
|
173
|
+
lines,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const built = await runComponentFile(template.text, template.fileName, options);
|
|
177
|
+
const finished = await finishRun({ dryRun: options.dryRun === true, allowBreaking: options.allowBreaking === true });
|
|
178
|
+
// The strays are breaking steps of the run, and what the end of it did is said among the notes.
|
|
179
|
+
built.report.steps.push(...strays.map((stray, at) => ({ kind: 'REMOVE_STRAY' as const, member: stray.name, breaks: lines[at] })));
|
|
180
|
+
built.report.breaking.push(...lines);
|
|
181
|
+
built.report.notes.push(...finished.moved, ...finished.notes);
|
|
182
|
+
postToUi({ type: 'COMPONENT_RUN', payload: built });
|
|
183
|
+
return true;
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (!(error instanceof ComponentRunError)) throw error;
|
|
186
|
+
postToUi({ type: 'ERROR', payload: { message: error.message, code: 'COMPONENT_RUN_FAILED', commandType, problems: error.problems } });
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
type Swap = Extract<Step, { kind: 'SWAP_COMPONENT' }>;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A dry build of one kept file, through the engine a build runs, for what a relink would swap: each component file
|
|
195
|
+
* as it is built, each body of a blueprint with its `library` lines declared. Nothing is written or imported. A file or
|
|
196
|
+
* body the engine stops on is a line, since its swaps are then not known.
|
|
197
|
+
*/
|
|
198
|
+
async function dryPass(template: KeptTemplate): Promise<{ swaps: Swap[]; stopped: string[] }> {
|
|
199
|
+
const swaps: Swap[] = [];
|
|
200
|
+
const stopped: string[] = [];
|
|
201
|
+
const pass = async (label: string, plan: () => Promise<Step[]>): Promise<void> => {
|
|
202
|
+
try {
|
|
203
|
+
swaps.push(...(await plan()).filter((step): step is Swap => step.kind === 'SWAP_COMPONENT'));
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (!(error instanceof ComponentRunError)) throw error;
|
|
206
|
+
stopped.push(`${label} could not be planned, so what it would swap is not known: ${[error.message, ...error.problems].join('; ')}`);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
if (!isBlueprintFile(template.fileName)) {
|
|
210
|
+
const read = readComponentText(template.text);
|
|
211
|
+
const errors = readErrors(read, template.fileName);
|
|
212
|
+
await pass(template.fileName, async () => {
|
|
213
|
+
if (errors.length > 0) throw new ComponentRunError(`${template.fileName} does not read`, errors);
|
|
214
|
+
return (await run(desiredOf(read), { dryRun: true })).steps;
|
|
215
|
+
});
|
|
216
|
+
return { swaps, stopped };
|
|
217
|
+
}
|
|
218
|
+
const read = readBlueprintText(template.text);
|
|
219
|
+
const options: RunOptions = { dryRun: true, libraries: read.libraries };
|
|
220
|
+
for (const body of read.bodies) {
|
|
221
|
+
if (!body.component) continue;
|
|
222
|
+
await pass(
|
|
223
|
+
`${template.fileName}, ${body.artboard ? 'artboard' : 'component'} "${body.component.name}"`,
|
|
224
|
+
async () => (await run(desiredOf(body), options)).steps,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return { swaps, stopped };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The relink, planned: a dry build of every kept file, keeping only the swaps, one row each (R14), and a line per
|
|
232
|
+
* library the swaps import from. Whether that library is published, and its update accepted here, the plugin cannot
|
|
233
|
+
* see, so the line says what the import needs rather than claiming it is missing (R11). Execute is each file's Build.
|
|
234
|
+
*/
|
|
235
|
+
export async function PLAN_RELINK(): Promise<void> {
|
|
236
|
+
const operations: Operation[] = [];
|
|
237
|
+
const from = new Map<string, number>();
|
|
238
|
+
let stopped = 0;
|
|
239
|
+
for (const template of loadTemplates()) {
|
|
240
|
+
const pass = await dryPass(template);
|
|
241
|
+
for (const swap of pass.swaps) {
|
|
242
|
+
operations.push({ kind: 'SWAP_COMPONENT', path: template.name, message: summarizeStep(swap).message ?? '' });
|
|
243
|
+
if (swap.library !== undefined) from.set(swap.library, (from.get(swap.library) ?? 0) + 1);
|
|
244
|
+
}
|
|
245
|
+
for (const line of pass.stopped) operations.push({ kind: 'WARNING', path: template.fileName, message: line });
|
|
246
|
+
stopped += pass.stopped.length;
|
|
247
|
+
}
|
|
248
|
+
for (const [library, count] of from) {
|
|
249
|
+
operations.push({
|
|
250
|
+
kind: 'NOTE',
|
|
251
|
+
message: `${count === 1 ? 'One swap imports' : `${count} swaps import`} from library "${library}" by key, which needs the library published and its update accepted in this file; the plugin cannot see whether it is. If the build says a key did not import, publish the library, then accept its update here.`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
const swaps = operations.filter((operation) => operation.kind === 'SWAP_COMPONENT').length;
|
|
255
|
+
if (swaps === 0)
|
|
256
|
+
operations.unshift({
|
|
257
|
+
kind: 'NOTE',
|
|
258
|
+
message: `${stopped > 0 ? 'In the files that could be planned, no' : 'No'} stamped instance would swap to another component.`,
|
|
259
|
+
});
|
|
260
|
+
postToUi({ type: 'PLAN_READY', payload: { planType: 'relink', operations, summary: { swaps, libraries: from.size, stopped } } });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* One kept blueprint's needs, looked up now: its tokens held against what Load loaded, its libraries against the
|
|
265
|
+
* lock, and each component it names answered by the locator, read only. A component found that a dry build would swap
|
|
266
|
+
* a stamped instance to reads `would swap`. The answer is a standing picture for the Build pane, dated (R15).
|
|
267
|
+
*/
|
|
268
|
+
export async function LOOK_UP_NEEDS(payload: Payload<'LOOK_UP_NEEDS'>): Promise<void> {
|
|
269
|
+
const template = loadTemplates().find((kept) => kept.name === payload.name);
|
|
270
|
+
if (!template || !isBlueprintFile(template.fileName)) {
|
|
271
|
+
postToUi({
|
|
272
|
+
type: 'ERROR',
|
|
273
|
+
payload: {
|
|
274
|
+
message: `No blueprint named "${payload.name}" is kept in this file.`,
|
|
275
|
+
code: 'INVALID_TEMPLATE',
|
|
276
|
+
commandType: 'LOOK_UP_NEEDS',
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const needs = blueprintNeeds(readBlueprintText(template.text));
|
|
282
|
+
const lock = loadLock();
|
|
283
|
+
for (const page of figma.root.children) await page.loadAsync();
|
|
284
|
+
const local = localComponents();
|
|
285
|
+
const libraries = declaredLibraries(needs.libraries, lock.overrides);
|
|
286
|
+
const pass = await dryPass(template);
|
|
287
|
+
|
|
288
|
+
const answer: NeedsPayload = {
|
|
289
|
+
name: template.name,
|
|
290
|
+
lookedAt: new Date().toISOString(),
|
|
291
|
+
libraries: [],
|
|
292
|
+
components: [],
|
|
293
|
+
notes: pass.stopped,
|
|
294
|
+
};
|
|
295
|
+
if (needs.tokens) answer.tokens = { ...needs.tokens, ...tokensAgainstLoad(needs.tokens.entries, needs.tokens.overrides) };
|
|
296
|
+
for (const name of needs.libraries) {
|
|
297
|
+
const readAs = lock.overrides?.[name];
|
|
298
|
+
const entry = lock.libraries[readAs ?? name];
|
|
299
|
+
answer.libraries.push({
|
|
300
|
+
name,
|
|
301
|
+
state: entry ? 'in the lock' : 'missing',
|
|
302
|
+
...(entry ? { by: entry.by } : {}),
|
|
303
|
+
...(readAs !== undefined ? { readAs } : {}),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
const named = (list: string[]): string => list.map((library) => `"${library}"`).join(' and ');
|
|
307
|
+
for (const need of needs.components) {
|
|
308
|
+
if (!('name' in need.ref)) {
|
|
309
|
+
answer.components.push({
|
|
310
|
+
name: `(key)"${need.ref.key}"`,
|
|
311
|
+
state: 'by key',
|
|
312
|
+
detail: 'a published key: a look imports nothing, so it is not checked',
|
|
313
|
+
});
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const name = need.ref.name;
|
|
317
|
+
if (need.satisfied) {
|
|
318
|
+
answer.components.push({ name, state: 'satisfied', detail: 'a component of this blueprint' });
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const located = locateComponent(name, libraries, lock, local);
|
|
322
|
+
const swapping = pass.swaps.filter((swap) => swap.to === located.name).length;
|
|
323
|
+
const swapped =
|
|
324
|
+
swapping > 0
|
|
325
|
+
? {
|
|
326
|
+
state: 'would swap' as const,
|
|
327
|
+
detail: `${swapping === 1 ? 'a stamped instance shows' : `${swapping} stamped instances show`} another component now`,
|
|
328
|
+
}
|
|
329
|
+
: undefined;
|
|
330
|
+
if (located.at === 'file') answer.components.push({ name, state: 'found', detail: 'in this file', ...swapped });
|
|
331
|
+
else if (located.at === 'library') answer.components.push({ name, state: 'from a library', library: located.library, ...swapped });
|
|
332
|
+
else if (located.at === 'key')
|
|
333
|
+
answer.components.push({
|
|
334
|
+
name,
|
|
335
|
+
state: 'by key',
|
|
336
|
+
detail: `the overrides map sends it to ${located.name}, not checked without importing it`,
|
|
337
|
+
...swapped,
|
|
338
|
+
});
|
|
339
|
+
else if (located.at === 'missing')
|
|
340
|
+
answer.components.push({ name, state: 'missing', detail: "not in this file, and no declared library's lock entry holds it" });
|
|
341
|
+
else if (located.at === 'ambiguous')
|
|
342
|
+
answer.components.push({ name, state: 'problem', detail: `this file has ${located.here.length} of that name` });
|
|
343
|
+
else if (located.at === 'both')
|
|
344
|
+
answer.components.push({
|
|
345
|
+
name,
|
|
346
|
+
state: 'problem',
|
|
347
|
+
detail: `in this file and in library ${named(located.libraries)}: a build stops on it`,
|
|
348
|
+
});
|
|
349
|
+
else
|
|
350
|
+
answer.components.push({
|
|
351
|
+
name,
|
|
352
|
+
state: 'problem',
|
|
353
|
+
detail: `in libraries ${named(located.libraries)}: name the one to use in the overrides map`,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
postToUi({ type: 'NEEDS', payload: answer });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const baseName = (path: string): string => path.split('/').pop() ?? path;
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* A blueprint's `tokens` held against what Load loaded: the entry by its files' names (the plugin's files have no
|
|
363
|
+
* folders), and whether a map was loaded with it. The plugin cannot read the map a blueprint names, so its hash is
|
|
364
|
+
* said and the two maps are not compared.
|
|
365
|
+
*/
|
|
366
|
+
function tokensAgainstLoad(
|
|
367
|
+
entries: string[],
|
|
368
|
+
overrides: string | undefined,
|
|
369
|
+
): { state: 'loaded' | 'differs' | 'not loaded'; detail?: string } {
|
|
370
|
+
const composition = getState().composition;
|
|
371
|
+
if (!composition) return { state: 'not loaded', detail: 'nothing is loaded: load it in the Load pane' };
|
|
372
|
+
const loaded = ([] as string[]).concat(composition.entry);
|
|
373
|
+
if (loaded.map(baseName).join('\n') !== entries.map(baseName).join('\n'))
|
|
374
|
+
return { state: 'differs', detail: `the loaded document is ${loaded.join(', ')}` };
|
|
375
|
+
const map = composition.overrides;
|
|
376
|
+
if (overrides !== undefined && !map) return { state: 'differs', detail: `loaded without the overrides map it names, ${overrides}` };
|
|
377
|
+
if (overrides === undefined && map)
|
|
378
|
+
return { state: 'differs', detail: `loaded with an overrides map it does not name (hash ${map.hash})` };
|
|
379
|
+
return {
|
|
380
|
+
state: 'loaded',
|
|
381
|
+
...(map ? { detail: `with an overrides map, hash ${map.hash}; the plugin cannot read ${overrides} to compare` } : {}),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* What the pane shows of each kept template: what the file declares, read
|
|
387
|
+
* again now, and where its set is. Every page is loaded to look, as the engine
|
|
388
|
+
* does before a build: a set on another page is still built.
|
|
389
|
+
*/
|
|
390
|
+
async function summarize(templates: readonly KeptTemplate[]): Promise<TemplatesPayload> {
|
|
391
|
+
const sets = new Map<string, { page: string; setId: string }>();
|
|
392
|
+
if (templates.length > 0) {
|
|
393
|
+
for (const page of figma.root.children) await page.loadAsync();
|
|
394
|
+
// A person's copy of a set is not the template's set.
|
|
395
|
+
for (const set of (await sortNodes(findInEveryPage({ types: ['COMPONENT_SET'], sharedPluginData: stampedAs('component') }))).ours) {
|
|
396
|
+
const name = stampOn(set)?.is.component;
|
|
397
|
+
if (name !== undefined && !sets.has(name)) sets.set(name, { page: pageOf(set), setId: set.id });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const summaries: TemplateSummary[] = templates.map((template) => {
|
|
402
|
+
if (isBlueprintFile(template.fileName)) {
|
|
403
|
+
// A blueprint's row lists its needs; what it holds is its bodies, each read as its file would be.
|
|
404
|
+
const read = readBlueprintText(template.text);
|
|
405
|
+
const needs = blueprintNeeds(read);
|
|
406
|
+
return {
|
|
407
|
+
name: template.name,
|
|
408
|
+
fileName: template.fileName,
|
|
409
|
+
kept: template.kept,
|
|
410
|
+
characters: template.text.length,
|
|
411
|
+
members: read.bodies.reduce((sum, body) => sum + body.members.length, 0),
|
|
412
|
+
axes: [],
|
|
413
|
+
properties: [],
|
|
414
|
+
tokenGroups: [],
|
|
415
|
+
builtInto: null,
|
|
416
|
+
differs: null,
|
|
417
|
+
problems: read.problems.filter((problem) => problem.severity === 'error').map((problem) => describe(template.fileName, problem)),
|
|
418
|
+
needs: {
|
|
419
|
+
...(needs.tokens ? { tokens: needs.tokens } : {}),
|
|
420
|
+
libraries: needs.libraries,
|
|
421
|
+
components: needs.components.map((need) => ({
|
|
422
|
+
name: 'name' in need.ref ? need.ref.name : `(key)"${need.ref.key}"`,
|
|
423
|
+
satisfied: need.satisfied,
|
|
424
|
+
})),
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
const read = readComponentText(template.text);
|
|
429
|
+
const component = read.component;
|
|
430
|
+
const properties = component?.properties ?? [];
|
|
431
|
+
return {
|
|
432
|
+
name: template.name,
|
|
433
|
+
fileName: template.fileName,
|
|
434
|
+
kept: template.kept,
|
|
435
|
+
characters: template.text.length,
|
|
436
|
+
members: read.members.length,
|
|
437
|
+
axes: properties
|
|
438
|
+
.filter((property): property is Extract<Property, { type: 'VARIANT' }> => property.type === 'VARIANT')
|
|
439
|
+
.map((property) => ({ name: property.name, values: [...property.values] })),
|
|
440
|
+
properties: properties
|
|
441
|
+
.filter((property) => property.type !== 'VARIANT')
|
|
442
|
+
.map((property) => ({ name: property.name, type: property.type })),
|
|
443
|
+
tokenGroups: Object.keys(component?.groups ?? {}),
|
|
444
|
+
builtInto: sets.get(template.name) ?? null,
|
|
445
|
+
// Nothing compares a set with its file yet: a dry run re-applies every declared field whether or not it changed.
|
|
446
|
+
differs: null,
|
|
447
|
+
problems: read.problems.filter((problem) => problem.severity === 'error').map((problem) => describe(template.fileName, problem)),
|
|
448
|
+
};
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
return { lookedAt: new Date().toISOString(), templates: summaries };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function pageOf(node: BaseNode): string {
|
|
455
|
+
let current: BaseNode | null = node;
|
|
456
|
+
while (current && current.type !== 'PAGE') current = current.parent;
|
|
457
|
+
return current?.name ?? '';
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function postToUi(response: Response): void {
|
|
461
|
+
figma.ui.postMessage(response);
|
|
462
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loaded document as a graph: every token, its value in each mode, what it
|
|
3
|
+
* points to and what points to it.
|
|
4
|
+
*
|
|
5
|
+
* The Tokens pane browses the document's own shape, so this reads the document
|
|
6
|
+
* and nothing in Figma. Where a token stands in Figma is `CORRESPONDENCE`'s
|
|
7
|
+
* answer; the screen merges the two by path. See `reference.md`, *The token graph*.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { cssPropertyName, normalizeColorToSRGB01, type Resolver } from '@toghen/lib';
|
|
11
|
+
import type { Response, TokenGraphEntry, TokenGraphPayload } from '../../shared/protocol.js';
|
|
12
|
+
import { getState } from '../runtimeState.js';
|
|
13
|
+
import { pathsInAnyMode, readToken, referrersOf, resolveSumModes } from '../utils/resolverModes.js';
|
|
14
|
+
|
|
15
|
+
export async function GET_TOKEN_GRAPH(): Promise<void> {
|
|
16
|
+
const { resolver } = getState();
|
|
17
|
+
if (!resolver) {
|
|
18
|
+
postToUi({
|
|
19
|
+
type: 'ERROR',
|
|
20
|
+
payload: { message: 'Load a resolver document first.', code: 'NO_RESOLVER', commandType: 'GET_TOKEN_GRAPH' },
|
|
21
|
+
});
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
postToUi({ type: 'TOKEN_GRAPH', payload: buildTokenGraph(resolver) });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Builds the graph from the document's sum: the defaults and each context on
|
|
29
|
+
* its own, which is what Figma holds. A document whose sum cannot be built (a
|
|
30
|
+
* modifier with no default) is refused at load, so none reaches here.
|
|
31
|
+
*/
|
|
32
|
+
export function buildTokenGraph(resolver: Resolver): TokenGraphPayload {
|
|
33
|
+
const resolution = resolveSumModes(resolver);
|
|
34
|
+
const { ds, dsCollectionName, modeNames: modes } = resolution;
|
|
35
|
+
// Every token, a token some context adds and the defaults do not have among them.
|
|
36
|
+
const paths = pathsInAnyMode(resolution);
|
|
37
|
+
const referrers = referrersOf(resolution, paths);
|
|
38
|
+
|
|
39
|
+
const tokens = [...paths.keys()].map((refPath): TokenGraphEntry => {
|
|
40
|
+
const path = `${dsCollectionName}.${refPath}`;
|
|
41
|
+
const valueByMode: Record<string, string | null> = {};
|
|
42
|
+
const references = new Set<string>();
|
|
43
|
+
let type: string | null | undefined;
|
|
44
|
+
for (const mode of modes) {
|
|
45
|
+
const token = readToken(ds, path, mode);
|
|
46
|
+
// A value the core refuses to compute has none to show; the plans say why.
|
|
47
|
+
valueByMode[mode] = token && token.refused === undefined ? displayValue(token) : null;
|
|
48
|
+
if (!token) continue;
|
|
49
|
+
if (type === undefined) type = token.$type ?? null;
|
|
50
|
+
for (const reference of ds.referencesOf(path, mode)) references.add(reference.to);
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
path,
|
|
54
|
+
type: type ?? null,
|
|
55
|
+
group: path.slice(0, path.lastIndexOf('.')),
|
|
56
|
+
valueByMode,
|
|
57
|
+
references: [...references],
|
|
58
|
+
referencedBy: [...new Set((referrers.get(path) ?? []).map((referrer) => referrer.from))],
|
|
59
|
+
codeSyntax: cssPropertyName(path),
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return { modes, tokens };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A resolved value written for a person to read: a colour as hex, a dimension
|
|
68
|
+
* with its unit, a font list joined, a composite named by its parts. For
|
|
69
|
+
* display only; nothing compares these strings.
|
|
70
|
+
*/
|
|
71
|
+
export function displayValue(token: { $type: string; $value?: unknown }): string {
|
|
72
|
+
const value: unknown = token.$value;
|
|
73
|
+
try {
|
|
74
|
+
switch (token.$type) {
|
|
75
|
+
case 'color':
|
|
76
|
+
return hexOf(value);
|
|
77
|
+
case 'dimension':
|
|
78
|
+
case 'letterSpacing':
|
|
79
|
+
case 'duration':
|
|
80
|
+
return lengthOf(value);
|
|
81
|
+
case 'fontFamily':
|
|
82
|
+
return Array.isArray(value) ? value.join(', ') : String(value);
|
|
83
|
+
case 'typography': {
|
|
84
|
+
const typography = value as Record<string, unknown>;
|
|
85
|
+
const family = Array.isArray(typography.fontFamily) ? typography.fontFamily[0] : typography.fontFamily;
|
|
86
|
+
return [typography.fontSize !== undefined ? lengthOf(typography.fontSize) : '', family ?? '', typography.fontWeight ?? '']
|
|
87
|
+
.filter((part) => part !== '')
|
|
88
|
+
.join(' ');
|
|
89
|
+
}
|
|
90
|
+
case 'shadow': {
|
|
91
|
+
const layers = Array.isArray(value) ? value : [value];
|
|
92
|
+
return layers.length === 1 ? 'shadow' : `shadow, ${layers.length} layers`;
|
|
93
|
+
}
|
|
94
|
+
default:
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// Fall through to the plain spelling.
|
|
99
|
+
}
|
|
100
|
+
return typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function lengthOf(value: unknown): string {
|
|
104
|
+
if (typeof value === 'object' && value !== null && 'value' in value) {
|
|
105
|
+
const { value: amount, unit } = value as { value: number; unit: string };
|
|
106
|
+
return `${amount}${unit}`;
|
|
107
|
+
}
|
|
108
|
+
return String(value);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function hexOf(value: unknown): string {
|
|
112
|
+
const color = normalizeColorToSRGB01(value as Parameters<typeof normalizeColorToSRGB01>[0]);
|
|
113
|
+
const byte = (channel: number) =>
|
|
114
|
+
Math.round(Math.min(1, Math.max(0, channel)) * 255)
|
|
115
|
+
.toString(16)
|
|
116
|
+
.padStart(2, '0');
|
|
117
|
+
return `#${byte(color.r)}${byte(color.g)}${byte(color.b)}${color.a < 1 ? byte(color.a) : ''}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function postToUi(response: Response): void {
|
|
121
|
+
figma.ui.postMessage(response);
|
|
122
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { DesignSystem, Resolver, ResolverDocument } from '@toghen/lib';
|
|
2
|
+
import type { PropertyConvention } from '../shared/conventions.js';
|
|
3
|
+
import type { Operation } from '../shared/operations.js';
|
|
4
|
+
import type { Composition, PlanType } from '../shared/protocol.js';
|
|
5
|
+
import { defaultTokenTypeMap, type TokenTypeConversionMap } from '../shared/tokenTypeMap.js';
|
|
6
|
+
import type { SimpleAbortController } from './utils/abortController.js';
|
|
7
|
+
import type { Stamp } from './components/stamp.js';
|
|
8
|
+
|
|
9
|
+
export interface PluginRuntimeState {
|
|
10
|
+
resolver: Resolver | null;
|
|
11
|
+
resolverDoc: ResolverDocument | null;
|
|
12
|
+
designSystem: DesignSystem | null;
|
|
13
|
+
/** The file the document was read from, when the load said; the screen's Source card names it. */
|
|
14
|
+
fileName: string | null;
|
|
15
|
+
/** What the load read and how; the document state carries it. */
|
|
16
|
+
composition: Composition | null;
|
|
17
|
+
|
|
18
|
+
conventions: PropertyConvention[];
|
|
19
|
+
conversionMap: TokenTypeConversionMap;
|
|
20
|
+
|
|
21
|
+
lastPlan: Operation[] | null;
|
|
22
|
+
lastPlanType: PlanType | null;
|
|
23
|
+
activeOperation: SimpleAbortController | null;
|
|
24
|
+
/**
|
|
25
|
+
* The stamps a dry run's load would have written in turning an older build's root record into stamps, by object id,
|
|
26
|
+
* read as the objects' own so its plans read as after the migration (`utils/rootRecord.ts`). `null` unless the
|
|
27
|
+
* document was loaded for a dry run.
|
|
28
|
+
*/
|
|
29
|
+
unwrittenStamps: Map<string, Stamp> | null;
|
|
30
|
+
}
|
|
31
|
+
// There was a `syncMetadata` here — `lastSyncedAt`, `pendingCount`,
|
|
32
|
+
// `errorCount` — written by both execute handlers and read by nothing at all.
|
|
33
|
+
// The two counts were dead outright, and what was synced is answered by the
|
|
34
|
+
// stamps on the file's own variables and styles, which survive closing the
|
|
35
|
+
// plugin as the in-memory copy never did. See `utils/syncState.ts`. Two sources
|
|
36
|
+
// of truth for one fact is how they begin to disagree.
|
|
37
|
+
|
|
38
|
+
const createInitialState = (): PluginRuntimeState => ({
|
|
39
|
+
resolver: null,
|
|
40
|
+
resolverDoc: null,
|
|
41
|
+
designSystem: null,
|
|
42
|
+
fileName: null,
|
|
43
|
+
composition: null,
|
|
44
|
+
|
|
45
|
+
conventions: [],
|
|
46
|
+
conversionMap: defaultTokenTypeMap,
|
|
47
|
+
|
|
48
|
+
lastPlan: null,
|
|
49
|
+
lastPlanType: null,
|
|
50
|
+
activeOperation: null,
|
|
51
|
+
unwrittenStamps: null,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
let state: PluginRuntimeState = createInitialState();
|
|
55
|
+
|
|
56
|
+
export function getState(): Readonly<PluginRuntimeState> {
|
|
57
|
+
return state;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function setState(patch: Partial<PluginRuntimeState>): void {
|
|
61
|
+
state = { ...state, ...patch };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resetState(): void {
|
|
65
|
+
state = createInitialState();
|
|
66
|
+
}
|