@design-twin/dtwin 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 +959 -2
- package/figma-plugin/code.js +4207 -0
- package/figma-plugin/manifest.json +28 -0
- package/figma-plugin/ui.html +686 -0
- package/layer/cross-check.js +2746 -0
- package/layer/drift-lint.js +1526 -0
- package/layer/get-component.js +553 -0
- package/layer/map-bootstrap.js +1404 -0
- package/layer/map-validate.js +397 -0
- package/package.json +53 -4
- package/src/abort-reason.ts +12 -0
- package/src/asset-compare.ts +175 -0
- package/src/asset-owners.ts +280 -0
- package/src/atomic-write.ts +79 -0
- package/src/bridge-holder.ts +294 -0
- package/src/cli-commands.ts +427 -0
- package/src/client-presence.ts +146 -0
- package/src/clock.ts +45 -0
- package/src/commands.ts +561 -0
- package/src/daemon-auth.ts +174 -0
- package/src/daemon.ts +788 -0
- package/src/delivery.ts +85 -0
- package/src/design-system-layout.ts +232 -0
- package/src/doc-types.ts +1054 -0
- package/src/doctor.ts +602 -0
- package/src/errmsg.ts +14 -0
- package/src/export-reader.ts +409 -0
- package/src/export-verdict.ts +258 -0
- package/src/figma-mcp.ts +942 -0
- package/src/figma-pull.ts +1265 -0
- package/src/file-lock.ts +268 -0
- package/src/file-memo.ts +62 -0
- package/src/hash.ts +6 -0
- package/src/hex-color.ts +39 -0
- package/src/human-ms.ts +7 -0
- package/src/init.ts +424 -0
- package/src/instance-overrides.ts +7 -0
- package/src/json-file.ts +79 -0
- package/src/json-size.ts +108 -0
- package/src/json-util.ts +51 -0
- package/src/library-layout.ts +193 -0
- package/src/node-id.ts +69 -0
- package/src/pages-layout.ts +483 -0
- package/src/pending-requests.ts +187 -0
- package/src/plugin-frames.ts +32 -0
- package/src/png.ts +196 -0
- package/src/ports.ts +25 -0
- package/src/project-layout.ts +189 -0
- package/src/quick-keys.ts +68 -0
- package/src/read-json.ts +67 -0
- package/src/read-opts.ts +125 -0
- package/src/read-plan.ts +105 -0
- package/src/read-run.ts +119 -0
- package/src/route.ts +243 -0
- package/src/seed-components.ts +229 -0
- package/src/served-session.ts +347 -0
- package/src/server-core.ts +1093 -0
- package/src/settings.ts +156 -0
- package/src/source-stamp.ts +16 -0
- package/src/staleness.ts +63 -0
- package/src/stdio.ts +156 -0
- package/src/svg-normalize.ts +205 -0
- package/src/timeouts.ts +76 -0
- package/src/timings.ts +118 -0
- package/src/token-store.ts +261 -0
- package/src/variables-merge.ts +337 -0
- package/src/verbs.ts +140 -0
- package/src/write-out.ts +1255 -0
package/src/commands.ts
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
// commands.ts — THE bridge command/reply contract: every command the plugin's `handleBridge`
|
|
2
|
+
// (figma-plugin/src/bridge.ts) accepts, with the argument shape each one reads and the reply shape
|
|
3
|
+
// each one returns. Both sides derive from this one map:
|
|
4
|
+
// - the plugin dispatches on `Cmd` (an exhaustive switch — a command added here without a handler
|
|
5
|
+
// fails to compile there);
|
|
6
|
+
// - server-core.ts / daemon.ts / figma-mcp.ts / figma-pull.ts type `request()` per command, so a
|
|
7
|
+
// caller cannot name a reply type the plugin never sends (a hand-mirror of `IndexReply`/`WhoamiReply`
|
|
8
|
+
// was stale once already: an `r.frames` the plugin never produced);
|
|
9
|
+
// - `replyShapeError` is the ONE structural check a reply passes at the point it enters the bridge
|
|
10
|
+
// process (server-core.ts), so a plugin answering the wrong shape is a named error, not a
|
|
11
|
+
// TypeError three modules later;
|
|
12
|
+
// - `parseCommandRequest` is its twin on the way IN: the plugin's `handleBridge` narrows the wire's
|
|
13
|
+
// `{ cmd, args }` to a `CommandRequest` through it, so no collector is handed an args shape that
|
|
14
|
+
// was only asserted;
|
|
15
|
+
// - the run traits (`runKind` / `isExportCmd`): which commands may run alongside which — the plugin's
|
|
16
|
+
// scheduler orders every run from them, and the bridge side reads `export` for its stall check,
|
|
17
|
+
// progress ticks and read plans.
|
|
18
|
+
//
|
|
19
|
+
// Dependency-free apart from read-opts.ts (itself dependency-free) and type imports: the plugin bundle imports it too, so it
|
|
20
|
+
// must type-check under figma-plugin/tsconfig.json (ES2019 lib, no Node types). Reply shapes that ARE
|
|
21
|
+
// documents on disk (ScreenExport, LayersDoc, DesignSystemDoc, VariablesDoc, Manifest, Asset) are
|
|
22
|
+
// referenced from doc-types.ts, never restated.
|
|
23
|
+
import type { Asset, DesignSystemDoc, LayersDoc, Manifest, ScreenExport, VariablesDoc } from "./doc-types.ts";
|
|
24
|
+
import { isRecord } from "./json-util.ts";
|
|
25
|
+
import { READ_OPTS, type ReadOptName } from "./read-opts.ts";
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------- arguments
|
|
28
|
+
|
|
29
|
+
/** The opt-in read options (read-opts.ts registry), as every walking export accepts them. */
|
|
30
|
+
export type ReadOptArgs = Partial<Record<ReadOptName, boolean>>;
|
|
31
|
+
/** A command that reads nothing from `args` (`{}` on the wire). */
|
|
32
|
+
export type NoArgs = Record<never, never>;
|
|
33
|
+
|
|
34
|
+
export interface ListPagesArgs {
|
|
35
|
+
/** 1 = page names only (loads nothing); 2 = pages + their top-level layers (the default). Named after
|
|
36
|
+
* the REST API's `depth` query parameter on GET /v1/files/:key ("Positive integer representing how
|
|
37
|
+
* deep into the document tree to traverse" — https://developers.figma.com/docs/rest-api/file-endpoints/),
|
|
38
|
+
* where depth 1 returns pages only and depth 2 pages and their top-level objects. The Plugin API has
|
|
39
|
+
* no such parameter; collect.ts listPages implements the two levels itself. */
|
|
40
|
+
depth?: 1 | 2;
|
|
41
|
+
}
|
|
42
|
+
export interface NodeArgs {
|
|
43
|
+
/** A node id (any form node-id.ts accepts — the plugin normalises it). */
|
|
44
|
+
nodeId: string;
|
|
45
|
+
}
|
|
46
|
+
/** collect.ts CollectOpts: the read options plus the page scope. `page` and `allPages` are exclusive. */
|
|
47
|
+
export interface ExportFullArgs extends ReadOptArgs {
|
|
48
|
+
allPages?: boolean;
|
|
49
|
+
page?: string | string[];
|
|
50
|
+
}
|
|
51
|
+
/** The catalog-only pulls accept ONE read option: variantVisuals enriches the component catalog they build. */
|
|
52
|
+
export interface DesignSystemArgs {
|
|
53
|
+
variantVisuals?: boolean;
|
|
54
|
+
}
|
|
55
|
+
export interface ExportLibraryArgs extends DesignSystemArgs {
|
|
56
|
+
/** The library's display name (what libraries/<slug>-<key>/ is named after). Defaults to figma.root.name. */
|
|
57
|
+
asLibrary?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface ExportNodeArgs extends ReadOptArgs, NodeArgs {}
|
|
60
|
+
export interface ScreenshotArgs extends NodeArgs {
|
|
61
|
+
/** Render scale override (default: auto, capped at 2048px on the longest side). Becomes the `value` of
|
|
62
|
+
* the `{ type: "SCALE", value }` ExportSettingsConstraints passed as ExportSettingsImage.constraint to
|
|
63
|
+
* exportAsync (assets.ts collectReference) — plugin-api.d.ts: `"SCALE"`: "The size of the exported
|
|
64
|
+
* image is proportional to the size of the exported layer in Figma. A `value` of 1 means the export
|
|
65
|
+
* is 100% of the layer size." (https://developers.figma.com/docs/plugins/api/ExportSettings). A value
|
|
66
|
+
* <= 0 falls back to the default there, as it always has. */
|
|
67
|
+
scale?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The four write ops figma-plugin/src/writes.ts implements (figma_write's Zod enum reads this list). */
|
|
71
|
+
export const WRITE_OP_NAMES = ["createFrame", "createText", "setFill", "setText"] as const;
|
|
72
|
+
export type WriteOpName = (typeof WRITE_OP_NAMES)[number];
|
|
73
|
+
/** One write op (writes.ts applyWrite imports it): only the fields each op actually reads. Every optional
|
|
74
|
+
* field admits `undefined` because figma_write hands over its Zod-parsed ops as-is, and Zod types an
|
|
75
|
+
* `.optional()` field as `T | undefined`; the plugin reads an undefined field exactly as an absent one. */
|
|
76
|
+
export interface WriteOp {
|
|
77
|
+
op: WriteOpName;
|
|
78
|
+
parentId?: string | undefined;
|
|
79
|
+
name?: string | undefined;
|
|
80
|
+
width?: number | undefined;
|
|
81
|
+
height?: number | undefined;
|
|
82
|
+
layoutMode?: "HORIZONTAL" | "VERTICAL" | undefined;
|
|
83
|
+
itemSpacing?: number | undefined;
|
|
84
|
+
padding?: [number, number, number, number] | undefined;
|
|
85
|
+
fill?: string | undefined;
|
|
86
|
+
text?: string | undefined;
|
|
87
|
+
fontSize?: number | undefined;
|
|
88
|
+
nodeId?: string | undefined;
|
|
89
|
+
color?: string | undefined;
|
|
90
|
+
}
|
|
91
|
+
export interface WriteArgs {
|
|
92
|
+
ops: WriteOp[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A WriteOp field that holds a string. */
|
|
96
|
+
type WriteStrField = "parentId" | "name" | "fill" | "text" | "nodeId" | "color";
|
|
97
|
+
/** What each write op IS, in one row — read by figma_write's dry run (writeOpPreview), the wire check
|
|
98
|
+
* (writeOpError — the gate a `write` command passes in the plugin's handleBridge, via parseCommandRequest, before
|
|
99
|
+
* writes.ts applyWrites runs; applyWrites repeats it for its direct callers). So a batch the preview calls invalid
|
|
100
|
+
* is one the plugin refuses before it touches the file, never one it half-applies. */
|
|
101
|
+
interface WriteOpRow {
|
|
102
|
+
/** creates = additive (a new node); overwrites = replaces a property on a node the user already has (the
|
|
103
|
+
* irreversible half). */
|
|
104
|
+
effect: "creates" | "overwrites";
|
|
105
|
+
/** The fields the op cannot run without. */
|
|
106
|
+
required: readonly WriteStrField[];
|
|
107
|
+
/** The new value the preview shows, by preview key, and the op field it is read from. */
|
|
108
|
+
shows: { newFill?: WriteStrField; newText?: WriteStrField };
|
|
109
|
+
}
|
|
110
|
+
// A Record keyed by the union, like COMMAND_SET: an op added to WRITE_OP_NAMES without a row fails to compile.
|
|
111
|
+
export const WRITE_OPS: { readonly [K in WriteOpName]: WriteOpRow } = {
|
|
112
|
+
createFrame: { effect: "creates", required: [], shows: {} },
|
|
113
|
+
createText: { effect: "creates", required: ["text"], shows: { newText: "text" } },
|
|
114
|
+
setFill: { effect: "overwrites", required: ["nodeId", "color"], shows: { newFill: "color" } },
|
|
115
|
+
setText: { effect: "overwrites", required: ["nodeId", "text"], shows: { newText: "text" } },
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** The fields op `op` requires that `fields` lacks (an undefined field is absent: see WriteOp). */
|
|
119
|
+
export function missingWriteFields(op: WriteOpName, fields: Partial<Record<WriteStrField, unknown>>): WriteStrField[] {
|
|
120
|
+
return WRITE_OPS[op].required.filter((k) => fields[k] === undefined);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** One step of figma_write's dry run: what op `index` would do, without doing it. */
|
|
124
|
+
export interface WritePreviewStep {
|
|
125
|
+
index: number;
|
|
126
|
+
op: WriteOpName;
|
|
127
|
+
effect: "creates" | "overwrites";
|
|
128
|
+
/** the node an overwrite changes (null when the op names none); where a create lands */
|
|
129
|
+
target: string | null;
|
|
130
|
+
newFill?: string | undefined;
|
|
131
|
+
newText?: string | undefined;
|
|
132
|
+
/** "missing <fields>" — the plugin refuses the batch up front for the same reason */
|
|
133
|
+
invalid?: string;
|
|
134
|
+
}
|
|
135
|
+
export function writeOpPreview(o: WriteOp, index: number): WritePreviewStep {
|
|
136
|
+
const row = WRITE_OPS[o.op];
|
|
137
|
+
const creates = row.effect === "creates";
|
|
138
|
+
const missing = missingWriteFields(o.op, o);
|
|
139
|
+
return {
|
|
140
|
+
index,
|
|
141
|
+
op: o.op,
|
|
142
|
+
effect: row.effect,
|
|
143
|
+
target: creates ? (o.parentId ? `new node under ${o.parentId}` : "new node on the current page") : o.nodeId || null,
|
|
144
|
+
...(row.shows.newFill ? { newFill: o[row.shows.newFill] } : {}),
|
|
145
|
+
...(row.shows.newText ? { newText: o[row.shows.newText] } : {}),
|
|
146
|
+
...(missing.length ? { invalid: `missing ${missing.join(", ")}` } : {}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------- replies
|
|
151
|
+
|
|
152
|
+
export interface PingReply {
|
|
153
|
+
pong: true;
|
|
154
|
+
page: string;
|
|
155
|
+
file: string;
|
|
156
|
+
/** only when figma.fileKey is readable (private-plugin API) */
|
|
157
|
+
fileKey?: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** bridge.ts `whoami`: the plugin instance's identity (see the handler's comment for what each answers). */
|
|
161
|
+
export interface WhoamiReply {
|
|
162
|
+
instanceId: string;
|
|
163
|
+
startedAt: number;
|
|
164
|
+
uptimeMs: number;
|
|
165
|
+
file: string;
|
|
166
|
+
page: string;
|
|
167
|
+
pageId: string;
|
|
168
|
+
editorType: string;
|
|
169
|
+
pluginVersion: string;
|
|
170
|
+
/** null when `figma.fileKey` is unavailable (not a private plugin) */
|
|
171
|
+
fileKey: string | null;
|
|
172
|
+
fileKeyAvailable: boolean;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** One selected node (figma.currentPage.selection, summarised). */
|
|
176
|
+
export interface SelectionEntry {
|
|
177
|
+
id: string;
|
|
178
|
+
name: string;
|
|
179
|
+
type: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** collect.ts summarize(): the ONLY shape the cheap index reads emit. `hasChildren` is listChildren-only. */
|
|
183
|
+
export interface NodeSummary {
|
|
184
|
+
name: string;
|
|
185
|
+
id: string;
|
|
186
|
+
type: string;
|
|
187
|
+
w?: number;
|
|
188
|
+
h?: number;
|
|
189
|
+
hidden?: true;
|
|
190
|
+
hasChildren?: boolean;
|
|
191
|
+
/** number of direct children, when they are readable */
|
|
192
|
+
childCount?: number;
|
|
193
|
+
/** first-visible-text title, only on rows that share name + size with another row of the same listing */
|
|
194
|
+
title?: string;
|
|
195
|
+
/** colliding rows whose titles do not tell them apart — ≤ 3 texts this row shows and its twins do not */
|
|
196
|
+
distinctTexts?: string[];
|
|
197
|
+
}
|
|
198
|
+
/** One page of a listPages reply. `frames` is present at depth 2 only, and absent on an `unreadable` page. */
|
|
199
|
+
export interface PageSummary {
|
|
200
|
+
name: string;
|
|
201
|
+
id: string;
|
|
202
|
+
current?: true;
|
|
203
|
+
unreadable?: true;
|
|
204
|
+
frames?: NodeSummary[];
|
|
205
|
+
}
|
|
206
|
+
export interface ListPagesReply {
|
|
207
|
+
exportedAt: string;
|
|
208
|
+
file: string;
|
|
209
|
+
depth: 1 | 2;
|
|
210
|
+
pages: PageSummary[];
|
|
211
|
+
manifest: { pages: number; frames?: number; warnings: string[] };
|
|
212
|
+
}
|
|
213
|
+
export interface ListChildrenReply {
|
|
214
|
+
exportedAt: string;
|
|
215
|
+
id: string;
|
|
216
|
+
name: string;
|
|
217
|
+
type: string;
|
|
218
|
+
children: NodeSummary[];
|
|
219
|
+
manifest: { children: number; warnings: string[] };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** One variable collection of a library (libraries.ts). `variableCount` is omitted when it could not be read. */
|
|
223
|
+
export interface LibraryCollection {
|
|
224
|
+
key: string;
|
|
225
|
+
name: string;
|
|
226
|
+
variableCount?: number;
|
|
227
|
+
}
|
|
228
|
+
/** One library row of a listLibraries reply — the open file itself (`kind: "local"`) or an enabled library. */
|
|
229
|
+
export interface LibraryRow {
|
|
230
|
+
key: string;
|
|
231
|
+
name: string;
|
|
232
|
+
kind: "local" | "library";
|
|
233
|
+
variableCollections: LibraryCollection[];
|
|
234
|
+
/** USAGE-derived (components of that library used in this file); absent when it cannot be attributed */
|
|
235
|
+
componentCount?: number;
|
|
236
|
+
/** what the row's counts do and do not mean; the plugin sets it on every row */
|
|
237
|
+
note: string;
|
|
238
|
+
}
|
|
239
|
+
export interface ListLibrariesReply {
|
|
240
|
+
exportedAt: string;
|
|
241
|
+
file?: string;
|
|
242
|
+
libraries: LibraryRow[];
|
|
243
|
+
warnings: string[];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** collect.ts collectFull: the design system + the page walk + every asset the walk exported. */
|
|
247
|
+
export interface FullExportReply {
|
|
248
|
+
designSystem: DesignSystemDoc;
|
|
249
|
+
layersDoc: LayersDoc;
|
|
250
|
+
assets: Asset[];
|
|
251
|
+
}
|
|
252
|
+
/** collect.ts collectDesignSystemOnly / collectLibraryFile: the catalog alone (a library's carries `source.role`). */
|
|
253
|
+
export interface DesignSystemReply {
|
|
254
|
+
designSystem: DesignSystemDoc;
|
|
255
|
+
}
|
|
256
|
+
/** collect.ts screenResult (collectSelection / collectNode): one screen doc, its token slice, its assets,
|
|
257
|
+
* and the page/node identity repeated at the top level for the writer's routing. */
|
|
258
|
+
export interface ScreenReply {
|
|
259
|
+
screenName: string;
|
|
260
|
+
screen: ScreenExport;
|
|
261
|
+
variables: VariablesDoc;
|
|
262
|
+
assets: Asset[];
|
|
263
|
+
page?: string;
|
|
264
|
+
pageId?: string;
|
|
265
|
+
nodeId?: string;
|
|
266
|
+
}
|
|
267
|
+
/** collect.ts collectScreenshot: one node's reference PNG (in `assets`, named by `reference`). */
|
|
268
|
+
export interface ScreenshotReply {
|
|
269
|
+
id: string;
|
|
270
|
+
name: string;
|
|
271
|
+
type: string;
|
|
272
|
+
reference: string;
|
|
273
|
+
manifest: Manifest;
|
|
274
|
+
assets: Asset[];
|
|
275
|
+
/** the node's width/height and the render scale the plugin used — absent from an older plugin. */
|
|
276
|
+
w?: number;
|
|
277
|
+
h?: number;
|
|
278
|
+
scale?: number;
|
|
279
|
+
}
|
|
280
|
+
/** Every reply write-out.ts knows how to land on disk. The members are told apart by the fields only
|
|
281
|
+
* they carry (`screen` / `reference` / `layersDoc`), which is what writeAny narrows on. */
|
|
282
|
+
export type ExportReply = FullExportReply | DesignSystemReply | ScreenReply | ScreenshotReply;
|
|
283
|
+
|
|
284
|
+
/** One applied write op: the id of the node it created or changed (writes.ts applyWrite). */
|
|
285
|
+
export interface AppliedWrite {
|
|
286
|
+
id?: string;
|
|
287
|
+
}
|
|
288
|
+
/** writes.ts applyWrites' reply: `ok: false` on a partial failure, with what was applied before it. */
|
|
289
|
+
export interface WriteResult {
|
|
290
|
+
ok: boolean;
|
|
291
|
+
applied: AppliedWrite[];
|
|
292
|
+
failedAt?: number;
|
|
293
|
+
failedOp?: string;
|
|
294
|
+
error?: string;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------- the map
|
|
298
|
+
|
|
299
|
+
export interface Commands {
|
|
300
|
+
ping: { args: NoArgs; reply: PingReply };
|
|
301
|
+
whoami: { args: NoArgs; reply: WhoamiReply };
|
|
302
|
+
getSelection: { args: NoArgs; reply: SelectionEntry[] };
|
|
303
|
+
listPages: { args: ListPagesArgs; reply: ListPagesReply };
|
|
304
|
+
listChildren: { args: NodeArgs; reply: ListChildrenReply };
|
|
305
|
+
listLibraries: { args: NoArgs; reply: ListLibrariesReply };
|
|
306
|
+
exportFull: { args: ExportFullArgs; reply: FullExportReply };
|
|
307
|
+
exportDesignSystem: { args: DesignSystemArgs; reply: DesignSystemReply };
|
|
308
|
+
exportLibrary: { args: ExportLibraryArgs; reply: DesignSystemReply };
|
|
309
|
+
exportSelection: { args: ReadOptArgs; reply: ScreenReply };
|
|
310
|
+
exportNode: { args: ExportNodeArgs; reply: ScreenReply };
|
|
311
|
+
screenshot: { args: ScreenshotArgs; reply: ScreenshotReply };
|
|
312
|
+
write: { args: WriteArgs; reply: WriteResult };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export type Cmd = keyof Commands;
|
|
316
|
+
|
|
317
|
+
/** A command with its arguments, as a discriminated union — `switch (req.cmd)` narrows `req.args`. */
|
|
318
|
+
export type CommandRequest = { [C in Cmd]: { cmd: C; args: Commands[C]["args"] } }[Cmd];
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------- run traits
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* How a command runs in the plugin relative to every other run in that file — the ONE rule, enforced by the
|
|
324
|
+
* plugin's run scheduler (figma-plugin/src/state.ts scheduleRun) for every caller alike: a CLI or MCP request
|
|
325
|
+
* through any bridge, and the plugin window's own export buttons.
|
|
326
|
+
* - exclusive: mutates the plugin's shared per-run state (assets, stats, warnings, memo caches) or the
|
|
327
|
+
* document. Runs alone, in arrival order, bracketed as a run (the window shows it; it can be cancelled).
|
|
328
|
+
* - scene: reads scene nodes and mutates nothing, but must not overlap an exclusive run. An export's
|
|
329
|
+
* catalogue walk holds figma.skipInvisibleInstanceChildren on across its awaits; a listing inside that
|
|
330
|
+
* window silently omits hidden layers of instances (listLibraries also toggles the flag itself), and
|
|
331
|
+
* getSelection's reads of a selected hidden instance layer throw (Plugin API docs,
|
|
332
|
+
* figma.skipInvisibleInstanceChildren). So it waits its turn on the same chain, but is not bracketed: it
|
|
333
|
+
* takes milliseconds, and the window has no export to show for it.
|
|
334
|
+
* - identity: reads only figma.root / figma.currentPage — no node walk. Answers at once, DURING an export:
|
|
335
|
+
* "is that file still there, and which file is it?" must not wait out a 12-minute pull.
|
|
336
|
+
*/
|
|
337
|
+
export type RunKind = "exclusive" | "scene" | "identity";
|
|
338
|
+
|
|
339
|
+
export interface CommandTraits {
|
|
340
|
+
run: RunKind;
|
|
341
|
+
/** Walks a tree into an export (a reply write-out.ts lands on disk) and can run for minutes: the commands a
|
|
342
|
+
* one-shot bridge's stall check and progress ticks are for (route.ts), and a read plan can send (read-plan.ts). */
|
|
343
|
+
export: boolean;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// The runtime twin of `Cmd`, carrying each command's traits. `satisfies` a Record keyed by the union, so the
|
|
347
|
+
// compiler checks it both ways (a command added to `Commands` without a row here fails to compile, and so does a
|
|
348
|
+
// stray row) while keeping each row's literal types for ExportCmd below.
|
|
349
|
+
const COMMAND_TRAITS = {
|
|
350
|
+
ping: { run: "identity", export: false },
|
|
351
|
+
whoami: { run: "identity", export: false },
|
|
352
|
+
getSelection: { run: "scene", export: false },
|
|
353
|
+
listPages: { run: "scene", export: false },
|
|
354
|
+
listChildren: { run: "scene", export: false },
|
|
355
|
+
listLibraries: { run: "scene", export: false },
|
|
356
|
+
exportFull: { run: "exclusive", export: true },
|
|
357
|
+
exportDesignSystem: { run: "exclusive", export: true },
|
|
358
|
+
exportLibrary: { run: "exclusive", export: true },
|
|
359
|
+
exportSelection: { run: "exclusive", export: true },
|
|
360
|
+
exportNode: { run: "exclusive", export: true },
|
|
361
|
+
screenshot: { run: "exclusive", export: true },
|
|
362
|
+
write: { run: "exclusive", export: false },
|
|
363
|
+
} as const satisfies Record<Cmd, CommandTraits>;
|
|
364
|
+
|
|
365
|
+
export function isCmd(x: string): x is Cmd {
|
|
366
|
+
return Object.prototype.hasOwnProperty.call(COMMAND_TRAITS, x);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Every command name, in declaration order. */
|
|
370
|
+
export const COMMANDS: readonly Cmd[] = Object.keys(COMMAND_TRAITS).filter(isCmd);
|
|
371
|
+
|
|
372
|
+
/** The commands whose traits say `export: true`. */
|
|
373
|
+
export type ExportCmd = { [C in Cmd]: (typeof COMMAND_TRAITS)[C]["export"] extends true ? C : never }[Cmd];
|
|
374
|
+
|
|
375
|
+
/** How `cmd` runs relative to other runs in its file (RunKind). */
|
|
376
|
+
export function runKind(cmd: Cmd): RunKind {
|
|
377
|
+
return COMMAND_TRAITS[cmd].run;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Is `cmd` a command whose traits say it is an export (any string: an unknown one is not). */
|
|
381
|
+
export function isExportCmd(cmd: string): cmd is ExportCmd {
|
|
382
|
+
return isCmd(cmd) && COMMAND_TRAITS[cmd].export;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ---------------------------------------------------------------- reply shape checks
|
|
386
|
+
|
|
387
|
+
// Each check names the FIRST thing wrong, in the caller's vocabulary ("`pages` is not an array"), so
|
|
388
|
+
// the message that reaches a user says what the plugin sent instead of what the reader tripped over.
|
|
389
|
+
// Deliberately light — the required top-level fields and their kind — the documents inside are the
|
|
390
|
+
// producer's contract (doc-types.ts), not re-validated here.
|
|
391
|
+
type Field = [name: string, kind: "string" | "number" | "boolean" | "array" | "object"];
|
|
392
|
+
|
|
393
|
+
function fields(r: unknown, spec: Field[]): string | null {
|
|
394
|
+
if (!isRecord(r)) return "not an object";
|
|
395
|
+
for (const [name, kind] of spec) {
|
|
396
|
+
const v = r[name];
|
|
397
|
+
const bad =
|
|
398
|
+
kind === "array" ? !Array.isArray(v)
|
|
399
|
+
: kind === "object" ? !isRecord(v)
|
|
400
|
+
: typeof v !== kind;
|
|
401
|
+
if (bad) return v === undefined ? `\`${name}\` is missing` : `\`${name}\` is not ${kind === "array" ? "an array" : kind === "object" ? "an object" : "a " + kind}`;
|
|
402
|
+
}
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const REPLY_CHECKS: { [C in Cmd]: (r: unknown) => string | null } = {
|
|
407
|
+
ping: (r) => (isRecord(r) && r.pong === true ? null : isRecord(r) ? "`pong` is not true" : "not an object"),
|
|
408
|
+
whoami: (r) => fields(r, [["instanceId", "string"], ["file", "string"]]),
|
|
409
|
+
getSelection: (r) => (Array.isArray(r) ? null : "not an array"),
|
|
410
|
+
listPages: (r) => fields(r, [["pages", "array"], ["manifest", "object"]]),
|
|
411
|
+
listChildren: (r) => fields(r, [["id", "string"], ["children", "array"], ["manifest", "object"]]),
|
|
412
|
+
listLibraries: (r) => fields(r, [["libraries", "array"], ["warnings", "array"]]),
|
|
413
|
+
exportFull: (r) => fields(r, [["designSystem", "object"], ["layersDoc", "object"], ["assets", "array"]]),
|
|
414
|
+
exportDesignSystem: (r) => fields(r, [["designSystem", "object"]]),
|
|
415
|
+
exportLibrary: (r) => fields(r, [["designSystem", "object"]]),
|
|
416
|
+
exportSelection: (r) => fields(r, [["screenName", "string"], ["screen", "object"], ["assets", "array"]]),
|
|
417
|
+
exportNode: (r) => fields(r, [["screenName", "string"], ["screen", "object"], ["assets", "array"]]),
|
|
418
|
+
screenshot: (r) => fields(r, [["id", "string"], ["reference", "string"], ["assets", "array"]]),
|
|
419
|
+
write: (r) => fields(r, [["ok", "boolean"], ["applied", "array"]]),
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
/** What is structurally wrong with `reply` for `cmd`, or null when it has the shape `Commands[cmd]["reply"]` promises. */
|
|
423
|
+
export function replyShapeError(cmd: Cmd, reply: unknown): string | null {
|
|
424
|
+
return REPLY_CHECKS[cmd](reply);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---------------------------------------------------------------- argument shape checks
|
|
428
|
+
|
|
429
|
+
/** What a node-scoped command answers when it has no node id. ONE string on purpose: the wire check
|
|
430
|
+
* below and the collectors (collect.ts, for their direct callers) both throw it, so a caller sees the
|
|
431
|
+
* same text whichever of them catches the omission. */
|
|
432
|
+
export const NO_NODE_ID = "No node id provided.";
|
|
433
|
+
|
|
434
|
+
// The argument twin of REPLY_CHECKS: each names the FIRST argument that does not have the type its
|
|
435
|
+
// `*Args` interface declares, or returns null. Two rules keep every call that worked before this
|
|
436
|
+
// check working:
|
|
437
|
+
// - an UNDECLARED key is ignored, never refused. The collectors have only ever read the keys they
|
|
438
|
+
// know (Run.useReads walks the read-option names, listPages reads `depth`, autoCss spreads and reads `css`),
|
|
439
|
+
// so an extra key — e.g. a read option sent to a catalog pull — has always been harmless.
|
|
440
|
+
// - `undefined` is "absent" (JSON drops it on the wire; in-process callers pass it for "default").
|
|
441
|
+
// `null` is NOT "absent" for an optional key (no typed sender produces it) — except `nodeId`, where the
|
|
442
|
+
// collectors have always mapped null to NO_NODE_ID, and that answer is kept.
|
|
443
|
+
type ArgsCheck = (a: Record<string, unknown>) => string | null;
|
|
444
|
+
|
|
445
|
+
const isBool = (v: unknown): v is boolean => typeof v === "boolean";
|
|
446
|
+
const isStr = (v: unknown): v is string => typeof v === "string";
|
|
447
|
+
const isNum = (v: unknown): v is number => typeof v === "number";
|
|
448
|
+
|
|
449
|
+
/** `key` is absent or passes `ok`. `what` completes "`label` is not …" (`label` defaults to `key`). */
|
|
450
|
+
function opt(a: Record<string, unknown>, key: string, ok: (v: unknown) => boolean, what: string, label: string = key): string | null {
|
|
451
|
+
const v = a[key];
|
|
452
|
+
return v === undefined || ok(v) ? null : `\`${label}\` is not ${what}`;
|
|
453
|
+
}
|
|
454
|
+
/** The first non-null of a list of checks, or null. */
|
|
455
|
+
function first(errs: Array<string | null>): string | null {
|
|
456
|
+
for (const e of errs) if (e !== null) return e;
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const READ_OPT_NAMES: readonly ReadOptName[] = READ_OPTS.map((o) => o.name);
|
|
461
|
+
const readOptArgs: ArgsCheck = (a) => first(READ_OPT_NAMES.map((k) => opt(a, k, isBool, "a boolean")));
|
|
462
|
+
const nodeArgs: ArgsCheck = (a) =>
|
|
463
|
+
a.nodeId === undefined || a.nodeId === null ? NO_NODE_ID : isStr(a.nodeId) ? null : "`nodeId` is not a string";
|
|
464
|
+
const designSystemArgs: ArgsCheck = (a) => opt(a, "variantVisuals", isBool, "a boolean");
|
|
465
|
+
const isPageSel = (v: unknown): boolean => isStr(v) || (Array.isArray(v) && v.every(isStr));
|
|
466
|
+
|
|
467
|
+
const isWriteOpName = (v: unknown): v is WriteOpName => isStr(v) && Object.prototype.hasOwnProperty.call(WRITE_OPS, v);
|
|
468
|
+
const isPadding = (v: unknown): boolean => Array.isArray(v) && v.length === 4 && v.every(isNum);
|
|
469
|
+
const WRITE_STR_FIELDS: readonly WriteStrField[] = ["parentId", "name", "fill", "text", "nodeId", "color"];
|
|
470
|
+
const WRITE_NUM_FIELDS = ["width", "height", "itemSpacing", "fontSize"];
|
|
471
|
+
/** Op `i` of a write batch against WriteOp: `op` names one of the four ops, every other declared field has its
|
|
472
|
+
* type, and the op's row in WRITE_OPS has every field it requires. The wire check (argsShapeError, which the
|
|
473
|
+
* plugin's handleBridge applies to every `write` before it runs) is this, and writes.ts applyWrites repeats it. */
|
|
474
|
+
export function writeOpError(o: unknown, i: number): string | null {
|
|
475
|
+
const at = `ops[${i}]`;
|
|
476
|
+
if (!isRecord(o)) return `\`${at}\` is not an object`;
|
|
477
|
+
const op = o.op;
|
|
478
|
+
if (!isWriteOpName(op)) return `\`${at}.op\` is not one of ${WRITE_OP_NAMES.join(" | ")}`;
|
|
479
|
+
const typeErr = first([
|
|
480
|
+
...WRITE_STR_FIELDS.map((k) => opt(o, k, isStr, "a string", `${at}.${k}`)),
|
|
481
|
+
...WRITE_NUM_FIELDS.map((k) => opt(o, k, isNum, "a number", `${at}.${k}`)),
|
|
482
|
+
opt(o, "layoutMode", (v) => v === "HORIZONTAL" || v === "VERTICAL", "HORIZONTAL or VERTICAL", `${at}.layoutMode`),
|
|
483
|
+
opt(o, "padding", isPadding, "four numbers", `${at}.padding`),
|
|
484
|
+
]);
|
|
485
|
+
if (typeErr !== null) return typeErr;
|
|
486
|
+
const missing = missingWriteFields(op, o);
|
|
487
|
+
return missing.length ? `\`${at}\` (${op}): missing ${missing.join(", ")}` : null;
|
|
488
|
+
}
|
|
489
|
+
const writeArgs: ArgsCheck = (a) => {
|
|
490
|
+
if (!Array.isArray(a.ops)) return a.ops === undefined ? "`ops` is missing" : "`ops` is not an array";
|
|
491
|
+
return first(a.ops.map(writeOpError));
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
/** NoArgs: nothing is read, so nothing can be wrong (see the undeclared-key rule above). */
|
|
495
|
+
const noArgs: ArgsCheck = () => null;
|
|
496
|
+
|
|
497
|
+
const ARGS_CHECKS: { [C in Cmd]: ArgsCheck } = {
|
|
498
|
+
ping: noArgs,
|
|
499
|
+
whoami: noArgs,
|
|
500
|
+
getSelection: noArgs,
|
|
501
|
+
listPages: (a) => opt(a, "depth", (v) => v === 1 || v === 2, "1 or 2"),
|
|
502
|
+
listChildren: nodeArgs,
|
|
503
|
+
listLibraries: noArgs,
|
|
504
|
+
exportFull: (a) => first([readOptArgs(a), opt(a, "allPages", isBool, "a boolean"), opt(a, "page", isPageSel, "a string or an array of strings")]),
|
|
505
|
+
exportDesignSystem: designSystemArgs,
|
|
506
|
+
exportLibrary: (a) => first([designSystemArgs(a), opt(a, "asLibrary", isStr, "a string")]),
|
|
507
|
+
exportSelection: readOptArgs,
|
|
508
|
+
exportNode: (a) => first([nodeArgs(a), readOptArgs(a)]),
|
|
509
|
+
screenshot: (a) => first([nodeArgs(a), opt(a, "scale", isNum, "a number")]),
|
|
510
|
+
write: writeArgs,
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
/** What is wrong with `args` for `cmd` — the text a caller sees — or null when it has the shape
|
|
514
|
+
* `Commands[cmd]["args"]` declares. A missing node id reads NO_NODE_ID verbatim (what the collectors
|
|
515
|
+
* answered before this check existed); anything else names the command it was sent to. */
|
|
516
|
+
export function argsShapeError(cmd: Cmd, args: unknown): string | null {
|
|
517
|
+
if (!isRecord(args)) return `bad ${cmd} args: not an object`;
|
|
518
|
+
const e = ARGS_CHECKS[cmd](args);
|
|
519
|
+
return e === null || e === NO_NODE_ID ? e : `bad ${cmd} args: ${e}`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** The type-level face of ARGS_CHECKS (a predicate cannot be read off a table of `string | null`). */
|
|
523
|
+
function hasArgs<C extends Cmd>(cmd: C, a: Record<string, unknown>): a is Record<string, unknown> & Commands[C]["args"] {
|
|
524
|
+
return ARGS_CHECKS[cmd](a) === null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export type ParsedRequest = { ok: true; req: CommandRequest } | { ok: false; error: string };
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* The wire's `{ cmd, args }` as a `CommandRequest`, or the error to answer with. `args` that is not an
|
|
531
|
+
* object (absent, null, an array) reads as `{}` — what handleBridge has always done — so an
|
|
532
|
+
* argument-less command keeps working whatever its sender put there, and a node-scoped one answers
|
|
533
|
+
* NO_NODE_ID. A switch rather than a table lookup because it is what lets each branch build its own
|
|
534
|
+
* `{ cmd, args }` member without an assertion: inside `case "x"` both `cmd` and (after the predicate)
|
|
535
|
+
* `a` have that command's types.
|
|
536
|
+
*/
|
|
537
|
+
export function parseCommandRequest(cmd: string, args: unknown): ParsedRequest {
|
|
538
|
+
if (!isCmd(cmd)) return { ok: false, error: "unknown cmd: " + cmd };
|
|
539
|
+
const a: Record<string, unknown> = isRecord(args) ? args : {};
|
|
540
|
+
const bad = (): ParsedRequest => ({ ok: false, error: argsShapeError(cmd, a) || `bad ${cmd} args` });
|
|
541
|
+
switch (cmd) {
|
|
542
|
+
case "ping": return hasArgs(cmd, a) ? { ok: true, req: { cmd, args: a } } : bad();
|
|
543
|
+
case "whoami": return hasArgs(cmd, a) ? { ok: true, req: { cmd, args: a } } : bad();
|
|
544
|
+
case "getSelection": return hasArgs(cmd, a) ? { ok: true, req: { cmd, args: a } } : bad();
|
|
545
|
+
case "listPages": return hasArgs(cmd, a) ? { ok: true, req: { cmd, args: a } } : bad();
|
|
546
|
+
case "listChildren": return hasArgs(cmd, a) ? { ok: true, req: { cmd, args: a } } : bad();
|
|
547
|
+
case "listLibraries": return hasArgs(cmd, a) ? { ok: true, req: { cmd, args: a } } : bad();
|
|
548
|
+
case "exportFull": return hasArgs(cmd, a) ? { ok: true, req: { cmd, args: a } } : bad();
|
|
549
|
+
case "exportDesignSystem": return hasArgs(cmd, a) ? { ok: true, req: { cmd, args: a } } : bad();
|
|
550
|
+
case "exportLibrary": return hasArgs(cmd, a) ? { ok: true, req: { cmd, args: a } } : bad();
|
|
551
|
+
case "exportSelection": return hasArgs(cmd, a) ? { ok: true, req: { cmd, args: a } } : bad();
|
|
552
|
+
case "exportNode": return hasArgs(cmd, a) ? { ok: true, req: { cmd, args: a } } : bad();
|
|
553
|
+
case "screenshot": return hasArgs(cmd, a) ? { ok: true, req: { cmd, args: a } } : bad();
|
|
554
|
+
case "write": return hasArgs(cmd, a) ? { ok: true, req: { cmd, args: a } } : bad();
|
|
555
|
+
default: {
|
|
556
|
+
// Exhaustive: a command added to `Commands` without a case here fails to compile.
|
|
557
|
+
const exhaustive: never = cmd;
|
|
558
|
+
return { ok: false, error: "unknown cmd: " + String(exhaustive) };
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|