@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
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// quick-keys.ts — the ONE text of the "Scripting quick keys": the export-JSON keys a script needs
|
|
2
|
+
// before it walks a screen file. Builders and verifiers kept guessing field names from Figma's Plugin
|
|
3
|
+
// API (`characters`, `width`, `visible`, `layout.scroll`) although ir-fields.md had the right ones —
|
|
4
|
+
// the reference was never opened by the agent writing the walker. So the same text lives in two
|
|
5
|
+
// places, both kept identical by a doc-guard (test/build-screen-docs.test.ts):
|
|
6
|
+
// - the top of claude-plugin/skills/build-screen/references/ir-fields.md, between the
|
|
7
|
+
// `quick-keys:start` / `quick-keys:end` markers;
|
|
8
|
+
// - design/export/SCHEMA.md, which write-out.ts writes next to the data on every pull that writes to
|
|
9
|
+
// disk (CLI + MCP writeToDisk), so a script-writing agent finds it where it looks.
|
|
10
|
+
// Edit the text HERE and paste it into ir-fields.md; the guard fails until both agree.
|
|
11
|
+
|
|
12
|
+
/** File name of the generated copy inside the export dir. Not README.md: in the legacy flat layout the
|
|
13
|
+
* export dir IS design/, where `dtwin init` writes its own design/README.md. */
|
|
14
|
+
export const SCHEMA_DOC_NAME = "SCHEMA.md";
|
|
15
|
+
|
|
16
|
+
/** First line of the generated copy. A SCHEMA.md without it is the user's file and is never touched. */
|
|
17
|
+
export const SCHEMA_MARKER = "<!-- generated by dtwin: scripting quick keys -->";
|
|
18
|
+
|
|
19
|
+
export const QUICK_KEYS = `## Scripting quick keys
|
|
20
|
+
|
|
21
|
+
Read these before writing ANY script against an export JSON (field names guessed from Figma's Plugin API are wrong).
|
|
22
|
+
|
|
23
|
+
- **The tree.** A screen file (\`pages/<Page>/<Screen>__<id>.json\`) holds it in \`nodes[]\` — a \`--node\` pull has exactly
|
|
24
|
+
one root, \`nodes[0]\`; a page-walk layer file holds it at \`tree\`. \`pages/index.json\` \`layers[]\` lists every screen
|
|
25
|
+
— \`file\` and \`texts\` on every row (\`title\` when the frame shows text); a \`--node\`/selection row also has \`w\`/\`h\`
|
|
26
|
+
and, when written, \`variables\`, \`assets\`, \`reference\` (a page-walk row has \`nodes\`/\`bytes\`; its \`reference\` is
|
|
27
|
+
in the layer file) — read it instead of building filenames.
|
|
28
|
+
- **Text** is \`text\`, not \`characters\`. Type: \`font.size\`, \`font.lineHeight\`, \`font.weightValue\`, \`font.letterSpacing\`,
|
|
29
|
+
\`font.family\`, \`font.color\` (there is no \`style\`). \`runs[]\` present = mixed formatting: \`font\` is then the FIRST
|
|
30
|
+
run's — read every run.
|
|
31
|
+
- **Size** is \`box.w\`/\`box.h\`; there are no top-level \`width\`/\`height\`. A single-screen index row repeats the size
|
|
32
|
+
as \`w\`/\`h\`.
|
|
33
|
+
- **Position.** \`box.x\`/\`box.y\` are PAGE (canvas) coordinates — frame-relative = \`box.x − <root>.box.x\` (root = \`nodes[0]\` in a screen file,
|
|
34
|
+
\`tree\` in a page-walk layer file). A node's
|
|
35
|
+
own \`x\`/\`y\` are relative to its nearest frame / component / instance / section ancestor (Figma skips GROUPs and
|
|
36
|
+
boolean operations; the page at top level). Both exist only when the parent does not lay the node out (no auto layout,
|
|
37
|
+
or \`absolute:true\`); inside an auto-layout parent the position comes from the parent's \`layout\` (padding, gap,
|
|
38
|
+
order). \`layout.inferred:true\` is a guess on a frame without auto layout — its children still carry \`x\`/\`y\`.
|
|
39
|
+
- **\`absolute:true\`** = out of the parent's auto-layout flow, placed by \`x\`/\`y\`; absent = in flow. \`layout\` describes
|
|
40
|
+
how a node lays out ITS OWN children, so only a node that can hold children has it: \`layout.mode:"absolute"\` =
|
|
41
|
+
no auto layout inside — it never means the node itself is absolute.
|
|
42
|
+
- **Child order.** \`children[]\` is Figma's layer list: paint order bottom → top (reversed when the parent's
|
|
43
|
+
\`layout.reverseZ\` is set). In an auto-layout parent it is also the flow order (first = leading); otherwise — and for
|
|
44
|
+
every \`absolute:true\` child — the visual order comes only from \`x\`/\`y\`.
|
|
45
|
+
- **Hidden layers stay in the tree.** \`hidden:true\` sits on the layer that was switched off; its descendants are not
|
|
46
|
+
flagged on its account (they may carry their own). Skip a node when it OR ANY ANCESTOR is hidden. There is no
|
|
47
|
+
node-level \`visible\` key.
|
|
48
|
+
- **Scroll.** \`clip:true\`; \`scroll\` (\`horizontal\`/\`vertical\`/\`both\`, a node field — not under \`layout\`);
|
|
49
|
+
\`fixedChildren:N\` = the LAST N entries of \`children[]\` stay pinned while the rest scrolls (Figma moves fixed layers
|
|
50
|
+
to the top of the layer list); fixed only — "sticky" is not exposed, so a Sticky child is not marked. Each pinned
|
|
51
|
+
child carries \`x\`/\`y\` (in an auto-layout frame Figma lets only an \`absolute:true\` child be fixed); place it by its
|
|
52
|
+
\`y\` (top bar → sticky top, bottom bar → sticky bottom).
|
|
53
|
+
- **Render bounds.** \`renderBox\` (only when it differs from \`box\`) = Figma's render bounds: larger than \`box\` for
|
|
54
|
+
shadow / stroke / blur; on a TEXT usually smaller than \`box\` (observed).
|
|
55
|
+
- **Three \`strokes\`.** \`strokes\` is always an object (\`colors[]\`, \`weight\` or \`weights{}\`, \`align\`…);
|
|
56
|
+
\`geometry.strokes\` is a list of SVG path strings; \`tokens.strokes\` is a variable name (or a list).
|
|
57
|
+
- **Tokens.** \`tokens.<property>\` = the bound variable's NAME — a string, or a list when several paints are bound;
|
|
58
|
+
paint-level \`fills[].tokens.color\`; text \`textTokens\` / \`runs[].tokens\`. Never map by the hex beside it.
|
|
59
|
+
- **Ids.** Inside an instance a node id looks like \`I<instance id>;<layer id>\` (observed) and differs per instance;
|
|
60
|
+
the component itself is \`mainComponent\` (\`key\`/\`setKey\`).
|
|
61
|
+
- **Files.** \`asset\` and \`reference\` are paths relative to \`design/export/\` (\`assets/<file>\`); \`assetFrom\` = a hidden
|
|
62
|
+
graphic reusing a visible twin's file; \`assetSkipped\` = no file at all. \`<Screen>.assets.json\`
|
|
63
|
+
(single-screen pulls) lists every file.
|
|
64
|
+
- **Older exports.** A file pulled with an older plugin also has \`gridColumnStart:-1\` / \`gridRowStart:-1\` on every node,
|
|
65
|
+
\`gridAlignSelf\` / \`gridJustifySelf\` on a node whose parent is not a grid, and \`layout.mode:"absolute"\` on every
|
|
66
|
+
TEXT, shape, SLICE, STICKY and other leaf type (a node TYPE that cannot hold children has no \`layout\`) — ignore all of it
|
|
67
|
+
(design-diff does).
|
|
68
|
+
`;
|
package/src/read-json.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// read-json.ts — the ONE way a JSON document is read off disk, by the bridge's export reader (export-reader.ts) and
|
|
2
|
+
// the whole design-to-code layer alike.
|
|
3
|
+
//
|
|
4
|
+
// readJson(file, guard) reads, parses and checks the file against a guard (design-to-code/doc-guards.ts holds one per
|
|
5
|
+
// document kind that layer reads; export-reader.ts holds the export index's and manifest's), and returns either the
|
|
6
|
+
// typed document or a one-line reason. It never throws and never exits: each caller keeps its own reporting convention
|
|
7
|
+
// (a CLI prints the reason and exits 2; a best-effort lookup treats an unreadable file as absent). This replaces the
|
|
8
|
+
// `JSON.parse(fs.readFileSync(f)) as T` reads — and the five generic `<T>(f) => … as T` helpers — that trusted every
|
|
9
|
+
// file to be what its name said.
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import { errMsg } from "./errmsg.ts";
|
|
12
|
+
|
|
13
|
+
/** A type guard that can say, in words, what it expects (readJson prints it on a mismatch). */
|
|
14
|
+
export type DocGuard<T> = ((x: unknown) => x is T) & { expected?: string };
|
|
15
|
+
|
|
16
|
+
/** readJson()'s result: the document, or why there is none. `missing` is set when the file does not exist. */
|
|
17
|
+
export type ReadResult<T> = { doc: T } | { error: string; missing?: true };
|
|
18
|
+
|
|
19
|
+
/** Any JSON value at all — for a caller that narrows the parsed value itself. */
|
|
20
|
+
export const anyJson: DocGuard<unknown> = (_x: unknown): _x is unknown => true;
|
|
21
|
+
|
|
22
|
+
// Why a read failed, in words (the same wording catalog-input.ts's readJsonFile has always printed).
|
|
23
|
+
function readFailure(e: unknown): { error: string; missing?: true } {
|
|
24
|
+
const code = e && typeof e === "object" && "code" in e ? e.code : undefined;
|
|
25
|
+
if (code === "ENOENT") return { error: "does not exist", missing: true };
|
|
26
|
+
return {
|
|
27
|
+
error: code === "EISDIR" ? "is a directory, not a file"
|
|
28
|
+
: code === "EACCES" ? "is not readable (permission denied)"
|
|
29
|
+
: `could not be read (${String(code || e)})`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Read + parse + check. The reason is a phrase that follows the file name: `'<file>' <error>`. */
|
|
34
|
+
export function readJson<T>(file: string, guard: DocGuard<T>): ReadResult<T> {
|
|
35
|
+
let buf: Buffer;
|
|
36
|
+
try { buf = fs.readFileSync(file); } catch (e) { return readFailure(e); }
|
|
37
|
+
// A UTF-16 BOM (FF FE little-endian, FE FF big-endian) means decoding as UTF-8 below would either
|
|
38
|
+
// throw or (worse) silently produce mojibake that then fails JSON.parse with a confusing error — catch
|
|
39
|
+
// it up front and say what is actually wrong.
|
|
40
|
+
if (buf.length >= 2 && ((buf[0] === 0xff && buf[1] === 0xfe) || (buf[0] === 0xfe && buf[1] === 0xff))) {
|
|
41
|
+
return { error: "is UTF-16, not UTF-8 — re-save it as UTF-8" };
|
|
42
|
+
}
|
|
43
|
+
let raw = buf.toString("utf8");
|
|
44
|
+
// A UTF-8 BOM (EF BB BF, decoded as U+FEFF) is invisible but not valid JSON syntax — strip it before
|
|
45
|
+
// parsing (verified: JSON.parse of a BOM'd file throws "Unexpected token" otherwise).
|
|
46
|
+
if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
|
|
47
|
+
let parsed: unknown;
|
|
48
|
+
try { const value: unknown = JSON.parse(raw); parsed = value; } catch (e) { return { error: `is not valid JSON — ${errMsg(e)}` }; }
|
|
49
|
+
if (!guard(parsed)) return { error: `is not ${guard.expected || "the expected kind of document"}` };
|
|
50
|
+
return { doc: parsed };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A regular file's bytes, or null when it is absent, unreadable or not a regular file (a directory, a FIFO, a device —
|
|
54
|
+
* a path a hand-edited document names must not hang a hook: /dev/zero never ends, a FIFO's open waits for a writer).
|
|
55
|
+
* Checked before the open (opening a FIFO blocks) and again on the open descriptor. */
|
|
56
|
+
export function readRegularFile(file: string): Buffer | null {
|
|
57
|
+
try { if (!fs.statSync(file).isFile()) return null; } catch { return null; }
|
|
58
|
+
let fd: number;
|
|
59
|
+
try { fd = fs.openSync(file, "r"); } catch { return null; }
|
|
60
|
+
try { return fs.fstatSync(fd).isFile() ? fs.readFileSync(fd) : null; } catch { return null; } finally { fs.closeSync(fd); }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The document, or null when it is absent, unreadable or the wrong shape — for best-effort lookups. */
|
|
64
|
+
export function readJsonOrNull<T>(file: string, guard: DocGuard<T>): T | null {
|
|
65
|
+
const r = readJson(file, guard);
|
|
66
|
+
return "doc" in r ? r.doc : null;
|
|
67
|
+
}
|
package/src/read-opts.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// read-opts.ts — THE registry of the extractor's opt-in read options.
|
|
2
|
+
//
|
|
3
|
+
// The same six names would otherwise be written out by hand in four places: the plugin's run reads
|
|
4
|
+
// (run.ts), the `CollectOpts` interface (collect.ts), the CLI's flag table (figma-pull.ts), and the
|
|
5
|
+
// MCP tool schema (figma-mcp.ts). Every one of those is a place a new option can be forgotten, and
|
|
6
|
+
// the failure is SILENT in both front-ends: the CLI just never sets the flag, and the MCP SDK's Zod
|
|
7
|
+
// object mode STRIPS undeclared keys before the handler runs. That is not hypothetical — `skipAssets`
|
|
8
|
+
// was once declared on figma_export_full only, which made it dead on the other two export tools with
|
|
9
|
+
// nothing to make the lists disagree loudly. Fixing that *within each file* would leave four
|
|
10
|
+
// consistent-by-luck lists instead of one.
|
|
11
|
+
//
|
|
12
|
+
// So: one row here is the whole change. `name` is the wire/option key the plugin reads, `flag` is the
|
|
13
|
+
// CLI spelling, `describe` is the MCP tool-schema help (the only consumer of prose, but it lives here
|
|
14
|
+
// so a new option cannot ship undocumented), `scopes` the reads that take it (see READ_SCOPES below).
|
|
15
|
+
//
|
|
16
|
+
// Dependency-free on purpose — the same arrangement as node-id.ts / pages-layout.ts / errmsg.ts:
|
|
17
|
+
// imported by the CLI and the MCP entry, and inlined into the plugin bundle by esbuild, so it must
|
|
18
|
+
// type-check under the plugin's tsconfig too (ES2019 lib, no Node types).
|
|
19
|
+
//
|
|
20
|
+
// ReadOptName is DERIVED from the registry below (`as const satisfies`), so the names are typed in
|
|
21
|
+
// exactly one place: CollectOpts and the run's reads both derive from it, and adding a row here makes
|
|
22
|
+
// TypeScript fail loudly at every site that must handle the new option rather than letting one
|
|
23
|
+
// drift silently.
|
|
24
|
+
|
|
25
|
+
// The read SCOPES — what one read exports. read-plan.ts turns a scope into the command that reads it; each
|
|
26
|
+
// row below declares the scopes that take it, and a read option given to any other scope is refused (the
|
|
27
|
+
// CLI) or not declared at all (each MCP tool's schema is derived from its scope). Declared as what each
|
|
28
|
+
// front-end accepted before the rows said so, not as what the plugin is known to read: variantVisuals on a
|
|
29
|
+
// node or selection pull builds no catalog, and a variant walk does serialize with the walk options on —
|
|
30
|
+
// both decisions for another day, and one row each when they are made.
|
|
31
|
+
export const READ_SCOPES = ["node", "selection", "pages", "designSystem", "library", "screenshot"] as const;
|
|
32
|
+
export type ReadScopeKind = (typeof READ_SCOPES)[number];
|
|
33
|
+
/** The scopes that walk nodes: every walk option is read there. */
|
|
34
|
+
const WALK = ["node", "selection", "pages"] as const;
|
|
35
|
+
|
|
36
|
+
export interface ReadOptDef {
|
|
37
|
+
/** The option key on the wire / in CollectOpts. */
|
|
38
|
+
name: string;
|
|
39
|
+
/** The figma-pull CLI spelling. */
|
|
40
|
+
flag: string;
|
|
41
|
+
/** Help text for the MCP tool schema. */
|
|
42
|
+
describe: string;
|
|
43
|
+
/** The read scopes that take this option. */
|
|
44
|
+
scopes: readonly ReadScopeKind[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const READ_OPTS = [
|
|
48
|
+
{
|
|
49
|
+
name: "css",
|
|
50
|
+
flag: "--css",
|
|
51
|
+
describe:
|
|
52
|
+
"Include Figma's OWN computed CSS per node (getCSSAsync) — the design-to-code oracle. One async call per node, so larger/slower; use for a screen you're implementing.",
|
|
53
|
+
scopes: WALK,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
// NOT "Dev Mode only": per developers.figma.com only addMeasurement/editMeasurement/
|
|
57
|
+
// deleteMeasurement carry that restriction — PageNode.getMeasurements() has no such note. And no
|
|
58
|
+
// longer "current page": collect.ts reads measurements from the page(s) actually exported.
|
|
59
|
+
name: "measurements",
|
|
60
|
+
flag: "--measurements",
|
|
61
|
+
describe:
|
|
62
|
+
"Include measurement redlines (spacing specs the designer placed) for the exported page(s). Reading them does not require Dev Mode.",
|
|
63
|
+
scopes: WALK,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "pluginData",
|
|
67
|
+
flag: "--plugin-data",
|
|
68
|
+
describe:
|
|
69
|
+
"Include own-scope plugin data (getPluginData) stamped on nodes — round-trip metadata. Usually empty unless the write plane wrote it.",
|
|
70
|
+
scopes: WALK,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "motion",
|
|
74
|
+
flag: "--motion",
|
|
75
|
+
describe:
|
|
76
|
+
"Include motion/animation reads (timelines, manual keyframe tracks, animations, applied animation styles). Free via the Plugin API; useful for Slides/prototype animation. Can be verbose.",
|
|
77
|
+
scopes: WALK,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "sharedData",
|
|
81
|
+
flag: "--shared-data",
|
|
82
|
+
describe:
|
|
83
|
+
"Include cross-plugin shared data (getSharedPluginData) — notably Tokens Studio applied tokens (the semantic token layer on files without native Figma Variables). Free; per-node.",
|
|
84
|
+
scopes: WALK,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "variantVisuals",
|
|
88
|
+
flag: "--variant-visuals",
|
|
89
|
+
describe:
|
|
90
|
+
"Walk each COMPONENT_SET's variant children and record their real layout/fills/radius/tokens/css — the master-component source of truth, not the component-set wrapper's own selection-chrome visuals. One node walk per variant; noticeably slower on large systems.",
|
|
91
|
+
scopes: [...WALK, "designSystem", "library"],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
// The odd one out: the others ADD work, this one REMOVES it. Asset export is one exportAsync (a
|
|
95
|
+
// real render round-trip — SVG per vector, 2x PNG per image) PER NODE, run sequentially, and
|
|
96
|
+
// unlike getCSSAsync it was never gated despite being the same O(nodes) shape and far more
|
|
97
|
+
// expensive. On a real design-system page it dominates the export and can outgrow the bridge's
|
|
98
|
+
// frame limit. Opt-IN to skipping, so the default stays exactly as it was.
|
|
99
|
+
name: "skipAssets",
|
|
100
|
+
flag: "--no-assets",
|
|
101
|
+
describe:
|
|
102
|
+
"Skip the per-node SVG/PNG render pass — the dominant cost on a big file. Structure, layout and tokens are unaffected; skipped nodes stay leaves marked `assetSkipped: true`. Asset BYTES are stripped from MCP results anyway, so this is usually pure savings here.",
|
|
103
|
+
scopes: WALK,
|
|
104
|
+
},
|
|
105
|
+
] as const satisfies readonly ReadOptDef[];
|
|
106
|
+
|
|
107
|
+
/** The literal union of every option key — "css" | "measurements" | … | "skipAssets". */
|
|
108
|
+
export type ReadOptName = (typeof READ_OPTS)[number]["name"];
|
|
109
|
+
|
|
110
|
+
/** A registry row as its readers take it: its name typed, its scopes widened to every scope (so a reader
|
|
111
|
+
* can ask whether a row lists any scope, not only one it already knows it lists). */
|
|
112
|
+
export type ReadOptRow = ReadOptDef & { name: ReadOptName };
|
|
113
|
+
export const READ_OPT_ROWS: readonly ReadOptRow[] = READ_OPTS;
|
|
114
|
+
|
|
115
|
+
/** The options a scope takes, as a literal union — what an MCP tool's schema for that scope declares. */
|
|
116
|
+
export type ReadOptNameFor<K extends ReadScopeKind> = RowNameFor<(typeof READ_OPTS)[number], K>;
|
|
117
|
+
type RowNameFor<R, K> = R extends { name: infer N; scopes: readonly (infer S)[] } ? (K extends S ? N : never) : never;
|
|
118
|
+
|
|
119
|
+
// Every option defaulted off, as a fresh object per call (callers fill it in — each run builds its own
|
|
120
|
+
// reads from one). Derived here so no consumer restates the names just to zero them.
|
|
121
|
+
export function readOptDefaults(): Record<ReadOptName, boolean> {
|
|
122
|
+
const o: Partial<Record<ReadOptName, boolean>> = {};
|
|
123
|
+
for (const d of READ_OPTS) o[d.name] = false;
|
|
124
|
+
return o as Record<ReadOptName, boolean>; // every key was just set by the loop above
|
|
125
|
+
}
|
package/src/read-plan.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// read-plan.ts — what a front-end sends for one read: the command, its arguments, its time budget and the
|
|
2
|
+
// line that names it, or the read options that scope does not take.
|
|
3
|
+
//
|
|
4
|
+
// Both front-ends used to build these themselves — the CLI in exportCmd/screenshotCmd, the MCP server in
|
|
5
|
+
// each export tool — and each one restated the rules: which command a scope is, that an empty page list is
|
|
6
|
+
// no selector, that a catalog pull takes variantVisuals alone, which budget tier a scope gets (the CLI from
|
|
7
|
+
// one call, the MCP server from five spelled-out constants). One function now, so a scope, a tier or a
|
|
8
|
+
// read option reaches both at once. The route (route.ts) then carries the plan to the plugin.
|
|
9
|
+
//
|
|
10
|
+
// The rules it owns:
|
|
11
|
+
// - the command per scope, and its arguments: every read option the scope takes is SAID, as a boolean, in
|
|
12
|
+
// registry order — never left out. The plugin's autoCss resolves only an absent `css`, so leaving one out
|
|
13
|
+
// would quietly switch CSS on for a small node; a decision the plan makes for every caller or none.
|
|
14
|
+
// - `page` is sent only when the list is non-empty (an empty list is the current page, not a selector);
|
|
15
|
+
// a blank entry IS sent, so collectFull warns about it. allPages with pages is sent as asked: collectFull
|
|
16
|
+
// refuses it, one guard for every caller (the CLI also refuses it while reading its arguments).
|
|
17
|
+
// - the refusal: a read option given as true to a scope whose row (read-opts.ts `scopes`) does not list it.
|
|
18
|
+
// Returned as the option names, not a message: each front-end words it in its own terms.
|
|
19
|
+
// - the budget: the scope's tier from TIMEOUTS, or the caller's override (the CLI's --timeout).
|
|
20
|
+
//
|
|
21
|
+
// Pure and side-effect free: figma-pull.ts and figma-mcp.ts import it at module level.
|
|
22
|
+
import { READ_OPT_ROWS } from "./read-opts.ts";
|
|
23
|
+
import { TIMEOUTS } from "./timeouts.ts";
|
|
24
|
+
import { ifDefined } from "./json-util.ts";
|
|
25
|
+
import type { ReadOptName, ReadOptRow, ReadScopeKind } from "./read-opts.ts";
|
|
26
|
+
import type { Commands, ExportCmd } from "./commands.ts";
|
|
27
|
+
|
|
28
|
+
export type { ReadScopeKind } from "./read-opts.ts";
|
|
29
|
+
|
|
30
|
+
/** One read: a scope and what it needs to name its target. */
|
|
31
|
+
export type ReadScope =
|
|
32
|
+
| { kind: "node"; nodeId: string }
|
|
33
|
+
| { kind: "selection" }
|
|
34
|
+
/** `pages` empty and `allPages` false: the current page. */
|
|
35
|
+
| { kind: "pages"; allPages: boolean; pages: readonly string[] }
|
|
36
|
+
| { kind: "designSystem" }
|
|
37
|
+
| { kind: "library"; name: string }
|
|
38
|
+
| { kind: "screenshot"; nodeId: string; scale?: number | undefined };
|
|
39
|
+
|
|
40
|
+
/** Read options as a caller has them: true asks, false or absent does not. Extra keys are ignored. */
|
|
41
|
+
export type ReadOptsGiven = Partial<Record<ReadOptName, boolean | undefined>>;
|
|
42
|
+
|
|
43
|
+
/** What a read sends: the commands whose run traits say `export` (commands.ts). */
|
|
44
|
+
export type ReadCmd = ExportCmd;
|
|
45
|
+
export interface PlanOf<C extends ReadCmd> {
|
|
46
|
+
cmd: C;
|
|
47
|
+
args: Commands[C]["args"];
|
|
48
|
+
/** The request's budget in ms. */
|
|
49
|
+
timeout: number;
|
|
50
|
+
/** What is being read, for a log line ("node 1:2", "page(s) A, B", "design system only", …). */
|
|
51
|
+
label: string;
|
|
52
|
+
}
|
|
53
|
+
export type ReadPlan = { [C in ReadCmd]: PlanOf<C> }[ReadCmd];
|
|
54
|
+
/** The read options given to a scope that does not take them, in registry order. */
|
|
55
|
+
export interface ReadRefusal { refused: ReadOptName[]; scope: ReadScopeKind }
|
|
56
|
+
|
|
57
|
+
/** The registry rows a scope takes, in registry order. */
|
|
58
|
+
export function readOptsFor(kind: ReadScopeKind): ReadOptRow[] {
|
|
59
|
+
return READ_OPT_ROWS.filter((o) => o.scopes.includes(kind));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The scope -> tier rule. A selection is small by construction; the whole file is the long walk; every other
|
|
63
|
+
// read (one node, a page or a few, a catalog, one screenshot) is an export's.
|
|
64
|
+
function tier(scope: ReadScope): number {
|
|
65
|
+
if (scope.kind === "selection") return TIMEOUTS.selection;
|
|
66
|
+
if (scope.kind === "pages" && scope.allPages) return TIMEOUTS.exportAll;
|
|
67
|
+
return TIMEOUTS.export;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function label(scope: ReadScope): string {
|
|
71
|
+
switch (scope.kind) {
|
|
72
|
+
case "node": return `node ${scope.nodeId}`;
|
|
73
|
+
case "selection": return "selection";
|
|
74
|
+
case "pages": return scope.allPages ? "all pages" : scope.pages.length ? `page(s) ${scope.pages.join(", ")}` : "current page";
|
|
75
|
+
case "designSystem": return "design system only";
|
|
76
|
+
case "library": return `library "${scope.name}"`;
|
|
77
|
+
case "screenshot": return `screenshot of ${scope.nodeId}`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every read option the scope takes, as a boolean, in registry order. */
|
|
82
|
+
function optsFor(kind: ReadScopeKind, given: ReadOptsGiven): Partial<Record<ReadOptName, boolean>> {
|
|
83
|
+
const o: Partial<Record<ReadOptName, boolean>> = {};
|
|
84
|
+
for (const { name } of readOptsFor(kind)) o[name] = given[name] === true;
|
|
85
|
+
return o;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function planRead(scope: Extract<ReadScope, { kind: "screenshot" }>, given?: ReadOptsGiven, o?: { timeoutMs?: number | undefined }): PlanOf<"screenshot"> | ReadRefusal;
|
|
89
|
+
export function planRead(scope: ReadScope, given?: ReadOptsGiven, o?: { timeoutMs?: number | undefined }): ReadPlan | ReadRefusal;
|
|
90
|
+
export function planRead(scope: ReadScope, given: ReadOptsGiven = {}, o: { timeoutMs?: number | undefined } = {}): ReadPlan | ReadRefusal {
|
|
91
|
+
const refused = READ_OPT_ROWS.filter((d) => given[d.name] === true && !d.scopes.includes(scope.kind)).map((d) => d.name);
|
|
92
|
+
if (refused.length) return { refused, scope: scope.kind };
|
|
93
|
+
const timeout = o.timeoutMs ?? tier(scope);
|
|
94
|
+
const at = { timeout, label: label(scope) };
|
|
95
|
+
const opts = optsFor(scope.kind, given);
|
|
96
|
+
switch (scope.kind) {
|
|
97
|
+
case "node": return { cmd: "exportNode", args: { nodeId: scope.nodeId, ...opts }, ...at };
|
|
98
|
+
case "selection": return { cmd: "exportSelection", args: opts, ...at };
|
|
99
|
+
case "pages": return { cmd: "exportFull", args: { allPages: scope.allPages, ...(scope.pages.length ? { page: [...scope.pages] } : {}), ...opts }, ...at };
|
|
100
|
+
case "designSystem": return { cmd: "exportDesignSystem", args: opts, ...at };
|
|
101
|
+
case "library": return { cmd: "exportLibrary", args: { asLibrary: scope.name, ...opts }, ...at };
|
|
102
|
+
// A screenshot's row set is empty, so `opts` is too: it renders a PNG and walks nothing.
|
|
103
|
+
case "screenshot": return { cmd: "screenshot", args: { nodeId: scope.nodeId, ...ifDefined("scale", scope.scale) }, ...at };
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/read-run.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// read-run.ts — one read, executed once: a read plan (read-plan.ts) sent through a route (route.ts), its progress
|
|
2
|
+
// relayed, cancellable, timed, and its reply stamped with WHICH connected Figma file answered (source-stamp.ts) before
|
|
3
|
+
// anything can write it out.
|
|
4
|
+
//
|
|
5
|
+
// Both front-ends ran this sequence themselves — the MCP server's pull() and the CLI's timed send plus timedWrite —
|
|
6
|
+
// each with its own clock, its own reading of the plugin's progress ticks, and its own `stampSource(...)` call that
|
|
7
|
+
// nothing made it remember. What is left to them is delivery: the CLI prints and writes (figma-pull.ts), the MCP server
|
|
8
|
+
// decides inline / written / refused (delivery.ts) and turns ticks into progress notifications.
|
|
9
|
+
//
|
|
10
|
+
// The rules it owns:
|
|
11
|
+
// - the plan is sent as planned: its command, arguments and budget, to the target the caller names.
|
|
12
|
+
// - progress: every tick reaches the caller's onProgress and the meter; the first `queued` tick (the run waits behind
|
|
13
|
+
// another in that file) is said once through onQueued; tickMessage words a tick (MCP's progress message).
|
|
14
|
+
// A run that asks for none of these asks the route for no ticks at all.
|
|
15
|
+
// - the clock: durationMs().request is the time inside the request (the meter's sent/replied included), writeMs the time
|
|
16
|
+
// inside write(), and total the run so far. A tally, when given, adds both to the caller's running totals
|
|
17
|
+
// (the CLI's `done in …` line and its DTWIN_TIMINGS record).
|
|
18
|
+
// - the stamp: the reply a run hands out is already stamped — write() hands it to the writer, so a writer never
|
|
19
|
+
// sees an unstamped reply.
|
|
20
|
+
import { stampSource } from "./source-stamp.ts";
|
|
21
|
+
import type { Commands, Cmd } from "./commands.ts";
|
|
22
|
+
import type { PlanOf, ReadCmd } from "./read-plan.ts";
|
|
23
|
+
import type { Route, Sent } from "./route.ts";
|
|
24
|
+
import type { ProgressTick } from "./server-core.ts";
|
|
25
|
+
import type { Stamped } from "./write-out.ts";
|
|
26
|
+
|
|
27
|
+
/** What hears a run's request, reply and ticks (timings.ts StageClock is one). */
|
|
28
|
+
export interface ReadMeter {
|
|
29
|
+
sent(): void;
|
|
30
|
+
replied(reply: unknown): void;
|
|
31
|
+
tick(phase: string | null): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Running totals a run adds its time to. */
|
|
35
|
+
export interface ReadTally { exportMs: number; writeMs: number }
|
|
36
|
+
|
|
37
|
+
/** How a request is measured: the meter that hears it, the totals it adds to, the clock. */
|
|
38
|
+
export interface Metering {
|
|
39
|
+
meter?: ReadMeter | null | undefined;
|
|
40
|
+
tally?: ReadTally | undefined;
|
|
41
|
+
now?: (() => number) | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ReadRunOptions extends Metering {
|
|
45
|
+
/** which connected file (route.ts resolves it) */
|
|
46
|
+
target?: string | null | undefined;
|
|
47
|
+
signal?: AbortSignal | undefined;
|
|
48
|
+
/** every progress tick, as it arrives */
|
|
49
|
+
onProgress?: ((tick: ProgressTick) => void) | undefined;
|
|
50
|
+
/** once, at the first `queued` tick */
|
|
51
|
+
onQueued?: (() => void) | undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One read, answered: its reply stamped with its source, and its clock. */
|
|
55
|
+
export interface ReadRun<C extends ReadCmd> {
|
|
56
|
+
readonly reply: Stamped<Commands[C]["reply"]>;
|
|
57
|
+
/** Runs the write-out on the stamped reply, timed. */
|
|
58
|
+
write<W>(out: (reply: Stamped<Commands[C]["reply"]>) => W): W;
|
|
59
|
+
/** total since the run started, the request, the write (0 until written) */
|
|
60
|
+
durationMs(): { total: number; request: number; write: number };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One request, measured: the meter hears it sent and answered, and its time is added to the tally's exportMs —
|
|
64
|
+
* a read's or any other command's (the CLI's index commands count toward the same line). */
|
|
65
|
+
export async function meteredRequest<C extends Cmd>(request: () => Promise<Sent<C>>, m: Metering = {}): Promise<{ sent: Sent<C>; ms: number }> {
|
|
66
|
+
const now = m.now ?? Date.now;
|
|
67
|
+
const t = now();
|
|
68
|
+
m.meter?.sent();
|
|
69
|
+
let ms: number | undefined;
|
|
70
|
+
try {
|
|
71
|
+
const sent = await request();
|
|
72
|
+
m.meter?.replied(sent.reply);
|
|
73
|
+
ms = now() - t;
|
|
74
|
+
return { sent, ms };
|
|
75
|
+
} finally {
|
|
76
|
+
// a request that fails counts too, up to its failure
|
|
77
|
+
if (m.tally) m.tally.exportMs += ms ?? now() - t;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A tick, said: its phase, then where the walk is ("pages, page 2 of 3 (Home), 120 nodes, 4 assets"). */
|
|
82
|
+
export function tickMessage(t: ProgressTick): string {
|
|
83
|
+
const parts = [t.phase ?? "export"];
|
|
84
|
+
if (t.page) parts.push(`page ${t.page.index} of ${t.page.of}${t.page.name ? ` (${t.page.name})` : ""}`);
|
|
85
|
+
if (t.nodes !== null) parts.push(`${t.nodes} nodes`);
|
|
86
|
+
if (t.assets !== null) parts.push(`${t.assets} assets`);
|
|
87
|
+
return parts.join(", ");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Runs `plan` through `route`. Rejects as the request does (a refusal, a timeout, a cancel). */
|
|
91
|
+
export async function runRead<C extends ReadCmd>(route: Pick<Route, "request">, plan: PlanOf<C>, o: ReadRunOptions = {}): Promise<ReadRun<C>> {
|
|
92
|
+
const now = o.now ?? Date.now;
|
|
93
|
+
const t0 = now();
|
|
94
|
+
const { meter, onProgress, onQueued } = o;
|
|
95
|
+
let queued = false;
|
|
96
|
+
const onTick = meter || onProgress || onQueued
|
|
97
|
+
? (tick: ProgressTick): void => {
|
|
98
|
+
meter?.tick(tick.phase);
|
|
99
|
+
if (tick.phase === "queued" && !queued) { queued = true; onQueued?.(); }
|
|
100
|
+
onProgress?.(tick);
|
|
101
|
+
}
|
|
102
|
+
: undefined;
|
|
103
|
+
const { sent, ms: requestMs } = await meteredRequest(
|
|
104
|
+
() => route.request(plan.cmd, plan.args, { timeoutMs: plan.timeout, target: o.target, onProgress: onTick, signal: o.signal }), o);
|
|
105
|
+
const reply = stampSource(sent);
|
|
106
|
+
let writeMs = 0;
|
|
107
|
+
return {
|
|
108
|
+
reply,
|
|
109
|
+
write(out) {
|
|
110
|
+
const t = now();
|
|
111
|
+
try { return out(reply); } finally {
|
|
112
|
+
const ms = now() - t;
|
|
113
|
+
writeMs += ms;
|
|
114
|
+
if (o.tally) o.tally.writeMs += ms;
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
durationMs: () => ({ total: now() - t0, request: requestMs, write: writeMs }),
|
|
118
|
+
};
|
|
119
|
+
}
|