@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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +959 -2
  3. package/figma-plugin/code.js +4207 -0
  4. package/figma-plugin/manifest.json +28 -0
  5. package/figma-plugin/ui.html +686 -0
  6. package/layer/cross-check.js +2746 -0
  7. package/layer/drift-lint.js +1526 -0
  8. package/layer/get-component.js +553 -0
  9. package/layer/map-bootstrap.js +1404 -0
  10. package/layer/map-validate.js +397 -0
  11. package/package.json +53 -4
  12. package/src/abort-reason.ts +12 -0
  13. package/src/asset-compare.ts +175 -0
  14. package/src/asset-owners.ts +280 -0
  15. package/src/atomic-write.ts +79 -0
  16. package/src/bridge-holder.ts +294 -0
  17. package/src/cli-commands.ts +427 -0
  18. package/src/client-presence.ts +146 -0
  19. package/src/clock.ts +45 -0
  20. package/src/commands.ts +561 -0
  21. package/src/daemon-auth.ts +174 -0
  22. package/src/daemon.ts +788 -0
  23. package/src/delivery.ts +85 -0
  24. package/src/design-system-layout.ts +232 -0
  25. package/src/doc-types.ts +1054 -0
  26. package/src/doctor.ts +602 -0
  27. package/src/errmsg.ts +14 -0
  28. package/src/export-reader.ts +409 -0
  29. package/src/export-verdict.ts +258 -0
  30. package/src/figma-mcp.ts +942 -0
  31. package/src/figma-pull.ts +1265 -0
  32. package/src/file-lock.ts +268 -0
  33. package/src/file-memo.ts +62 -0
  34. package/src/hash.ts +6 -0
  35. package/src/hex-color.ts +39 -0
  36. package/src/human-ms.ts +7 -0
  37. package/src/init.ts +424 -0
  38. package/src/instance-overrides.ts +7 -0
  39. package/src/json-file.ts +79 -0
  40. package/src/json-size.ts +108 -0
  41. package/src/json-util.ts +51 -0
  42. package/src/library-layout.ts +193 -0
  43. package/src/node-id.ts +69 -0
  44. package/src/pages-layout.ts +483 -0
  45. package/src/pending-requests.ts +187 -0
  46. package/src/plugin-frames.ts +32 -0
  47. package/src/png.ts +196 -0
  48. package/src/ports.ts +25 -0
  49. package/src/project-layout.ts +189 -0
  50. package/src/quick-keys.ts +68 -0
  51. package/src/read-json.ts +67 -0
  52. package/src/read-opts.ts +125 -0
  53. package/src/read-plan.ts +105 -0
  54. package/src/read-run.ts +119 -0
  55. package/src/route.ts +243 -0
  56. package/src/seed-components.ts +229 -0
  57. package/src/served-session.ts +347 -0
  58. package/src/server-core.ts +1093 -0
  59. package/src/settings.ts +156 -0
  60. package/src/source-stamp.ts +16 -0
  61. package/src/staleness.ts +63 -0
  62. package/src/stdio.ts +156 -0
  63. package/src/svg-normalize.ts +205 -0
  64. package/src/timeouts.ts +76 -0
  65. package/src/timings.ts +118 -0
  66. package/src/token-store.ts +261 -0
  67. package/src/variables-merge.ts +337 -0
  68. package/src/verbs.ts +140 -0
  69. package/src/write-out.ts +1255 -0
@@ -0,0 +1,1255 @@
1
+ // write-out.ts — the ONE place an export lands on disk.
2
+ //
3
+ // Both read clients write through this module: the figma-pull CLI (which writes to disk)
4
+ // and the MCP export tools (which strip asset bytes from their replies to keep megabytes out of the
5
+ // agent's context — without a disk path an MCP-only session could never produce assets/, and the CLI
6
+ // could not be run to fill the gap because both bind port 8787 and the second one to start exits on
7
+ // EADDRINUSE). Writing here and returning a compact INDEX is what makes
8
+ // the MCP path complete on its own.
9
+ //
10
+ // The freshness stamp (`exportedAt`/`file`) is stamped by the PLUGIN (collect.ts/components.ts).
11
+ // Nothing here adds, strips or re-derives it — writeJson writes what it was handed, byte for byte,
12
+ // because export-verdict.ts and design-to-code/drift-lint.ts both read that stamp back off disk.
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { writeFileAtomic } from "./atomic-write.ts";
16
+ import { buildPageLayout, screenPaths, mergeScreenIndex, mergeRootIndex, deriveTitle, collectTexts } from "./pages-layout.ts";
17
+ import type { ScreenPaths, ScreenIndexRow } from "./pages-layout.ts";
18
+ import { buildDesignSystemLayout } from "./design-system-layout.ts";
19
+ import type { DesignSystemLayout } from "./design-system-layout.ts";
20
+ import { buildLibraryLayout, mergeLibrariesIndex, ROOT, INDEX } from "./library-layout.ts";
21
+ import { mergeVariablesDoc } from "./variables-merge.ts";
22
+ import { sha1Hex, normalizeForCompare, ContentIndex, contentKey, fileKeyCache, type ContentKey } from "./asset-compare.ts";
23
+ import { isRasterShell } from "./svg-normalize.ts";
24
+ import { collectGraphics, reuseHiddenAssets, usesByPointer } from "./asset-owners.ts";
25
+ import { FileMemo } from "./file-memo.ts";
26
+ import type { FileMemoOptions } from "./file-memo.ts";
27
+ import type { AssetContext, AssetOwner, Graphic, ReuseResult } from "./asset-owners.ts";
28
+ import { MERGED_VARIABLES_FILE } from "./project-layout.ts";
29
+ import { exportRoot } from "./export-reader.ts";
30
+ import { ifDefined, isRecord } from "./json-util.ts";
31
+ import { errMsg, errCode } from "./errmsg.ts";
32
+ import { pngSize } from "./png.ts";
33
+ import { QUICK_KEYS, SCHEMA_DOC_NAME, SCHEMA_MARKER } from "./quick-keys.ts";
34
+ import type { Asset, DesignSystemDoc, IrNode, LayersDoc, Manifest, PagesRootIndex, VariablesDoc, LibraryCounts } from "./doc-types.ts";
35
+ import type { DesignSystemReply, ExportReply, FullExportReply, ScreenReply, ScreenshotReply } from "./commands.ts";
36
+
37
+ // ---- what the plugin hands this module (the `r` of every writer below): the per-command replies of
38
+ // commands.ts. writeAny tells them apart by the field only that reply carries (`screen`, `reference`),
39
+ // which is a real narrowing of the union, not a guess over one optional bag.
40
+
41
+ /** `sourceFile`/`sourceFileKey` are stamped by figma-pull.ts onto the reply before it reaches a
42
+ * writer — never by the plugin. */
43
+ export type Stamped<R> = R & { sourceFile?: string; sourceFileKey?: string };
44
+
45
+ export type Log = (m: string) => void;
46
+
47
+ /** What a write did to files already on disk — returned (as `changes`) only when the write keeps `.prev` copies
48
+ * (keepPrev: the MCP server's implicit spill). Absolute paths. */
49
+ export interface ChangeReport {
50
+ /** The `.prev` copies this write made: each JSON it replaced with different content (stamps aside). */
51
+ kept: string[];
52
+ /** The pages/ layer files that were on disk before this write (a page walk only), changed or not. */
53
+ replaced: string[];
54
+ /** `.prev` files an earlier write left that are still on disk, untouched by this one. */
55
+ earlier: string[];
56
+ }
57
+
58
+ // outDir is resolved against the CURRENT WORKING DIRECTORY on purpose: for the MCP server that is
59
+ // the project Claude Code was started in, so an export lands in the project you are building — not
60
+ // next to the bridge's own source. FIGMA_EXPORT_DIR is the same env var every reader honours
61
+ // (export-reader.ts exportRoot), so figma_status looks for the snapshot exactly where this wrote it.
62
+ //
63
+ // The default is design/EXPORT, not design/. design/ had become two kinds of file under one name:
64
+ // what a pull writes and may overwrite (pages/, design-system/, assets/, variables.json) and what a
65
+ // human or a build step owns and cannot regenerate (target.json, the component map, plan/, audit/,
66
+ // verify/). "Delete design/ and re-pull" is the obvious recovery move and it silently destroyed the
67
+ // second kind. One subdirectory draws the line where it can be seen: everything dtwin writes is under
68
+ // design/export/, and nothing else is.
69
+ //
70
+ // The precedence is export-reader.ts exportRoot's — the one every reader reads by — so a project still on the older
71
+ // flat layout (its export directly in design/) is written where it is read, as the CLI's positional default always did.
72
+ function resolveOutDir(outDir?: string | null): string {
73
+ return exportRoot(outDir);
74
+ }
75
+
76
+ // The CLI's outDir is a positional the USER typed — an absolute path or a sibling directory there is
77
+ // a legitimate choice. The MCP's outDir is a tool argument the MODEL supplies, and `path.resolve`
78
+ // happily honours "../../.." or "/tmp/anything", so an export could land anywhere the process can
79
+ // write. Confine that side to the directory the server was started in — which is also exactly what
80
+ // the option promises ("relative to your project"). Trust boundary, not a filesystem subtlety:
81
+ // applied where the value is untrusted, not inside resolveOutDir, so the CLI keeps its freedom.
82
+ // `what` names the ARGUMENT in the message: this guards exportDir and map too, and telling a model its
83
+ // `outDir` is wrong when it never passed one sends it looking for the wrong thing.
84
+ //
85
+ // Inside LEXICALLY, or inside once both sides are resolved through symlinks. On macOS /tmp, /var and
86
+ // /etc are symlinks into /private, and process.cwd() is getcwd() (already resolved) while a path the model
87
+ // builds from PWD or os.tmpdir() is not — so `/tmp/proj/design` was refused for a server started in
88
+ // /tmp/proj. The returned path stays the lexical one (path.resolve has already folded every `..`, so a
89
+ // `..` after a symlink cannot steer the write). A link inside the project that points outside is accepted,
90
+ // as it always was lexically; a path whose real location is outside (`<alias>/x`, alias → elsewhere) is not.
91
+ //
92
+ // A server started in the filesystem root (`/`, or a drive root on Windows — some MCP hosts launch stdio
93
+ // servers there) accepts only the root itself: every path is "inside" `/`, so treating it like any other
94
+ // cwd would let a model-chosen outDir/exportDir/map reach anywhere. So nothing below the root is accepted there.
95
+ const isFsRoot = (p: string): boolean => path.parse(p).root === p;
96
+ function isInside(dir: string, root: string): boolean {
97
+ if (dir === root) return true;
98
+ return !isFsRoot(root) && dir.startsWith(root + path.sep);
99
+ }
100
+ // realpath of the longest EXISTING prefix with the not-yet-existing tail re-appended: a fresh outDir does
101
+ // not exist yet, and realpath of a missing path throws.
102
+ function realish(p: string): string {
103
+ const tail: string[] = [];
104
+ let head = p;
105
+ for (;;) {
106
+ try {
107
+ return path.join(fs.realpathSync.native(head), ...tail);
108
+ } catch {
109
+ const up = path.dirname(head);
110
+ if (up === head) return p;
111
+ tail.unshift(path.basename(head));
112
+ head = up;
113
+ }
114
+ }
115
+ }
116
+ function assertInsideCwd(outDir?: string | null, what = "outDir"): string {
117
+ const dir = resolveOutDir(outDir);
118
+ const root = path.resolve(process.cwd());
119
+ if (isInside(dir, root)) return dir;
120
+ const real = realish(dir);
121
+ if (isInside(real, realish(root))) return dir;
122
+ throw new Error(
123
+ `${what} must stay inside the directory this server was started in (${root}) — got '${outDir}' -> ${dir}` +
124
+ (real !== dir ? ` (real path ${real})` : "") + ". " +
125
+ (isFsRoot(root)
126
+ ? `This server was started in the filesystem root, where no ${what} is accepted — start it in your project directory.`
127
+ : "Pass a relative path like 'design' or 'src/design'.")
128
+ );
129
+ }
130
+
131
+ // quiet: layer files are written by the hundred and get ONE summary line instead of one line each.
132
+ //
133
+ // Written through atomic-write.ts (a tmp file beside the target, then a rename): a run killed mid-write
134
+ // (Ctrl-C, OOM on a big export) leaves the PREVIOUS file intact instead of a truncated JSON that every later
135
+ // reader fails on.
136
+ //
137
+ // `keep`: when passed, the file this write replaces is first copied to `<file>.prev` if its content
138
+ // differs (keepPrevCopy's rule: one level, stamps ignored) and the .prev path is pushed onto `keep`.
139
+ function writeJson(dir: string, name: string, obj: unknown, quiet?: boolean, log?: Log, keep?: string[]): void {
140
+ const file = path.join(dir, name);
141
+ const text = JSON.stringify(obj, null, 2);
142
+ if (keep) { const prev = keepPrevCopy(file, text); if (prev) keep.push(prev); }
143
+ writeFileAtomic(file, text);
144
+ if (!quiet && log) log("wrote " + file);
145
+ }
146
+
147
+ // SCHEMA.md (scripting quick keys) beside the data: the field names a script needs, where a script-writing
148
+ // agent looks for them (the full reference, ir-fields.md, lives in the plugin and was never opened). The
149
+ // text is quick-keys.ts's QUICK_KEYS — one source with the ir-fields.md copy. The note names the reference
150
+ // by NAME, never a path: written files carry no filesystem paths.
151
+ // absent → written; marked + identical → left alone (no mtime change, so a dev server's file watcher
152
+ // sees nothing); marked + different → rewritten (info line); no marker → the user's own file, never touched.
153
+ // A failure here (read-only dir, SCHEMA.md is a directory) is a warning, never a failed pull.
154
+ const SCHEMA_NOTE = "<!-- Generated by dtwin, rewritten only when its text changes — do not edit. The full field reference is ir-fields.md in the designtwin plugin's build-screen skill. -->";
155
+ export const SCHEMA_DOC_TEXT = SCHEMA_MARKER + "\n" + SCHEMA_NOTE + "\n\n" + QUICK_KEYS;
156
+ function writeSchemaDoc(dir: string, log?: Log): void {
157
+ const file = path.join(dir, SCHEMA_DOC_NAME);
158
+ try {
159
+ let prev: string | null = null;
160
+ try { prev = fs.readFileSync(file, "utf8"); } catch (e) { if (errCode(e) !== "ENOENT") throw e; }
161
+ if (prev !== null) {
162
+ if (!prev.startsWith(SCHEMA_MARKER)) {
163
+ if (log) log(`warn ${SCHEMA_DOC_NAME} is not dtwin's (no generated marker on its first line) — the scripting quick keys were not written`);
164
+ return;
165
+ }
166
+ if (prev === SCHEMA_DOC_TEXT) return;
167
+ }
168
+ writeFileAtomic(file, SCHEMA_DOC_TEXT);
169
+ if (log && prev !== null) log(`info updated ${SCHEMA_DOC_NAME} (scripting quick keys)`);
170
+ } catch (e) {
171
+ if (log) log(`warn could not write ${SCHEMA_DOC_NAME}: ${errMsg(e)}`);
172
+ }
173
+ }
174
+
175
+ // Materialise the shared pages/ layout as real nested directories. The layout itself — bucketing,
176
+ // page-dir disambiguation, layer filenames, index shape, pageDirs — lives in pages-layout.ts, which
177
+ // the plugin's browser-download twin builds from too (with "__" instead of "/"), so the two writers
178
+ // cannot drift. This function only creates directories and writes bytes.
179
+ //
180
+ // `replaced`: when passed, every layer file already on disk before this write is pushed onto it (absolute path,
181
+ // once each) — the files this write replaces.
182
+ function writePages(dir: string, layersDoc: LayersDoc | null | undefined, log?: Log, keep?: string[], replaced?: string[]): { meta: PagesRootIndex; layerFiles: number; pageDirs: number; rootIndex: string } {
183
+ const pdir = path.join(dir, "pages");
184
+ fs.mkdirSync(pdir, { recursive: true }); // guarantee pages/ exists even for a zero-layer run
185
+ const { meta, layerFiles, indexFiles, rootIndex } = buildPageLayout(layersDoc, "/");
186
+ for (const p of meta.pageDirs) fs.mkdirSync(path.join(pdir, p.dir), { recursive: true }); // once per PAGE, not per layer
187
+ const seen = new Set<string>();
188
+ for (const f of layerFiles) {
189
+ if (replaced) {
190
+ const file = path.join(dir, f.path);
191
+ // A path written twice in one export was on disk before it only if the FIRST write found it.
192
+ if (!seen.has(file)) { seen.add(file); if (fs.existsSync(file)) replaced.push(file); }
193
+ }
194
+ writeJson(dir, f.path, f.data, true, undefined, keep);
195
+ }
196
+ for (const f of indexFiles) writeJson(dir, f.path, f.data, false, log, keep);
197
+ writeJson(dir, rootIndex, meta, false, log, keep);
198
+ if (log) log("wrote " + layerFiles.length + " layer file(s) across " + meta.pageDirs.length + " page dir(s) under " + pdir);
199
+ return { meta, layerFiles: layerFiles.length, pageDirs: meta.pageDirs.length, rootIndex };
200
+ }
201
+
202
+ // The design-system twin of writePages: the SPLIT (which keys land in which file, the local/library
203
+ // component partition, the slim manifest) lives in design-system-layout.ts, which the plugin's
204
+ // browser-download path builds from too, so the two writers cannot drift. This only writes bytes.
205
+ function writeDesignSystem(dir: string, designSystem: DesignSystemDoc | null | undefined, log?: Log, keep?: string[]): DesignSystemLayout["counts"] {
206
+ const built = buildDesignSystemLayout(designSystem, "/");
207
+ fs.mkdirSync(path.join(dir, built.dir), { recursive: true });
208
+ // COMPONENT_SET detail files land one level deeper, at manifest.files.componentsDir — only created
209
+ // when at least one entry actually produced a detail file (variantVisuals opt-in).
210
+ // Present only when a detail file was actually produced (design-system-layout.ts omits the pointer
211
+ // otherwise, so `files` never names a directory that does not exist).
212
+ const componentsDir = built.manifest.files.componentsDir;
213
+ if (componentsDir && built.files.some((f) => f.path.startsWith(componentsDir + "/"))) {
214
+ fs.mkdirSync(path.join(dir, componentsDir), { recursive: true });
215
+ }
216
+ for (const f of built.files) writeJson(dir, f.path, f.data, false, log, keep);
217
+ const counts = built.counts;
218
+ return counts;
219
+ }
220
+
221
+ // The library twin of writeDesignSystem. Writes into libraries/<slug>-<fileKey8>/ — a tree that by
222
+ // construction never intersects design-system/ or pages/, so a library pull cannot overwrite the design
223
+ // file's own catalog (they are separate Figma files answering separate questions; see library-layout.ts).
224
+ //
225
+ // Re-export is idempotent — every file is rewritten in place with a fresh stamp — but writeJson only
226
+ // ever WRITES, so a file a previous export produced and this one did not stays on disk, stale yet
227
+ // carrying a believable old timestamp. Rather than delete files a read command did not create, we diff
228
+ // this run's pointer map against the previous index and REPORT what is now orphaned. The user decides.
229
+ function writeLibrary(dir: string, designSystem: DesignSystemDoc | null | undefined, log?: Log, keep?: string[]): { dir: string; counts: LibraryCounts; publish: Record<string, number> | undefined; orphans: string[] } {
230
+ const built = buildLibraryLayout(designSystem, "/");
231
+ const ldir = path.join(dir, built.dir);
232
+
233
+ let prevIndexDoc: { files?: unknown } | null = null;
234
+ try { prevIndexDoc = JSON.parse(fs.readFileSync(path.join(ldir, INDEX), "utf8")) as { files?: unknown }; } catch { /* absent/corrupt = first export */ }
235
+
236
+ fs.mkdirSync(ldir, { recursive: true });
237
+ for (const f of built.files) writeJson(dir, f.path, f.data, false, log, keep);
238
+
239
+ const orphans: string[] = [];
240
+ // `files` is the pointer map ({kind: path}); anything else (a string, an array) is a corrupt index —
241
+ // walking it would report its characters/entries as orphans.
242
+ const prevFiles = prevIndexDoc && prevIndexDoc.files;
243
+ if (prevFiles && typeof prevFiles === "object" && !Array.isArray(prevFiles)) {
244
+ const now = new Set(built.files.map((f) => f.path));
245
+ for (const p of Object.values(prevFiles)) {
246
+ if (typeof p === "string" && !now.has(p) && fs.existsSync(path.join(dir, p))) orphans.push(p);
247
+ }
248
+ }
249
+ if (orphans.length && log) {
250
+ log("STALE: " + orphans.length + " file(s) from a previous export are no longer produced and were NOT deleted: " + orphans.join(", "));
251
+ }
252
+
253
+ // Merge, never overwrite: each library is its own plugin run in its own file, so exporting library B
254
+ // must not erase library A's row.
255
+ const rootIndex = path.join(dir, ROOT, INDEX);
256
+ let prevRoot: unknown = null;
257
+ try { prevRoot = JSON.parse(fs.readFileSync(rootIndex, "utf8")) as unknown; } catch { /* first export */ }
258
+ writeJson(dir, ROOT + "/" + INDEX, mergeLibrariesIndex(prevRoot, built), false, log, keep);
259
+
260
+ return { dir: built.dir, counts: built.counts, publish: built.manifest.publish, orphans };
261
+ }
262
+
263
+ // A flattened illustration arrives as one SVG with thousands of <path> elements. The live run hit a
264
+ // 2.47 MB empty-state graphic that inlined into a 2.71 MB JS bundle — moving that ONE file to a URL
265
+ // import cut the bundle 11x. The export is faithful; the problem is that nothing said so
266
+ // until a bundler did, hours later. Flag it here, where the bytes are in hand and the answer is still
267
+ // "ask the designer to re-export it as a PNG", not "redraw it".
268
+ const BIG_ASSET_BYTES = 250 * 1024;
269
+ const BUSY_SVG_PATHS = 400;
270
+
271
+ // assets/ is SHARED and CUMULATIVE across pulls (design/README.md — accumulate, don't
272
+ // replace, is deliberate). What is NOT acceptable is a later pull silently overwriting an
273
+ // earlier screen's file under the same name — including a same-name collision
274
+ // that differs only in CASE, which is one path on macOS's default case-insensitive filesystem
275
+ // (`angle-left.svg` vs `Angle-left.svg`). So every write here is refuse-or-version, never clobber:
276
+ // if a file already on disk (matched case-INSENSITIVELY, since that is the filesystem's own rule)
277
+ // has different bytes than what this pull is about to write, the NEW one gets a content-hash suffix
278
+ // instead of overwriting — mirroring exactly the suffixing the plugin's own `register()` does for a
279
+ // same-run collision (figma-plugin/src/assets.ts). Identical bytes are left alone (no-op, not a
280
+ // rewrite) so an unrelated re-pull doesn't touch a file's mtime for nothing.
281
+ //
282
+ // `a.file` is mutated in place to the name ACTUALLY written. That alone does NOT move the node tree's
283
+ // pointers (`asset` on each node, `reference` on the root), which the plugin built from its own names:
284
+ // callers run writeAssets BEFORE writing the tree and then rewireAssetPointers() (otherwise
285
+ // nodes point at names never written, and on a case-insensitive disk at the wrong file).
286
+ function readExistingDirCaseFold(adir: string): Map<string, string> {
287
+ const map = new Map<string, string>(); // lowercased basename -> real basename on disk
288
+ let names: string[] = [];
289
+ try { names = fs.readdirSync(adir); } catch { /* doesn't exist yet */ }
290
+ for (const n of names) map.set(n.toLowerCase(), n);
291
+ return map;
292
+ }
293
+
294
+ // The CONTENT half of dedup, keyed independent of name — the gap the name-only check above cannot
295
+ // close. A name-only check reuses `angle-left.svg` only when the NEW asset is ALSO named
296
+ // `angle-left.svg` (mod case); it writes a second copy of the exact same icon under a name the plugin
297
+ // (or a different screen's pull) happened to pick instead: a real
298
+ // three-screen pull showed that, with the name check in place, it still produced `Ellipse_2327.svg`,
299
+ // `Ellipse_2327-e9af26.svg`, `Ellipse_2327-51bd18.svg` AND `Ellipse_2327-51bd18_1.svg` — four files,
300
+ // one icon — because each pull's own suffix (assigned by the PLUGIN's per-run `byName`, or by an
301
+ // earlier version of this file) was a different, equally-valid-looking name, and writeAssets only ever
302
+ // asked "is anything ALREADY WRITTEN under THIS exact name" instead of "does this content already
303
+ // exist ANYWHERE in the shared directory".
304
+ //
305
+ // "The same content" is asset-compare.ts ContentIndex: an SVG fingerprint — def ids
306
+ // canonical, every number within SVG_TOL — or the exact bytes for anything else. A hash of v1's rounded
307
+ // text split real re-exports on three gaps (ids that embed the node id, `12` vs `11.9999`, a value either
308
+ // side of a 0.1 rounding boundary), so 11 copies of one icon stayed 11 files. Built ONCE per writeScreen /
309
+ // writeExport over the files ALREADY on disk, updated as writeAssets writes, and handed on to
310
+ // writeScreenAssets' `duplicates` — one scan, one rule, so a file writeAssets reuses can never show up
311
+ // as a `duplicates` entry it did not also see. A file unchanged since an earlier pull in this process is
312
+ // not read again (asset-compare.ts fileKeyCache; file-memo.ts says when a remembered key may be used).
313
+ function buildContentIndex(adir: string, skip?: ReadonlySet<string>): ContentIndex {
314
+ const index = new ContentIndex();
315
+ let names: string[] = [];
316
+ try { names = fs.readdirSync(adir); } catch { return index; }
317
+ for (const name of names) {
318
+ if (skip && skip.has(name)) continue;
319
+ const key = fileKeyCache.keyOf(path.join(adir, name), name);
320
+ if (key) index.addKey(name, key);
321
+ }
322
+ return index;
323
+ }
324
+
325
+ // One pull's ContentIndex of assets/, built on first use rather than up front: a pull whose assets never consult it —
326
+ // a reference PNG alone (it reuses only its own copies, ownCopyWithContent), with no hidden node reusing another
327
+ // screen's file — never scans the directory. A file writeAssets writes before that first use is held back and added
328
+ // after the scan, in write order: exactly the index an up-front build updated by writeAssets would be, so `duplicates`
329
+ // lists the same groups in the same order either way.
330
+ class PullIndex {
331
+ private readonly adir: string;
332
+ private index: ContentIndex | null = null;
333
+ private readonly pending: Array<{ name: string; key: ContentKey }> = [];
334
+ constructor(adir: string) { this.adir = adir; }
335
+ get(): ContentIndex {
336
+ if (this.index === null) {
337
+ const index = buildContentIndex(this.adir, new Set(this.pending.map((p) => p.name)));
338
+ for (const p of this.pending) index.addKey(p.name, p.key);
339
+ this.pending.length = 0;
340
+ this.index = index;
341
+ }
342
+ return this.index;
343
+ }
344
+ add(name: string, key: ContentKey): void {
345
+ if (this.index !== null) this.index.addKey(name, key);
346
+ else this.pending.push({ name, key });
347
+ }
348
+ }
349
+
350
+ // A file of THIS asset's own name family — `baseName` itself or `<stem>-<6 hex>[_N]<ext>`, the only names
351
+ // writeAssets ever gives it (matched case-insensitively, the disk's own rule) — whose content hashes to
352
+ // `hash`, or undefined. `names` maps lower-cased name → real name for everything on disk or claimed.
353
+ function ownCopyWithContent(adir: string, names: ReadonlyMap<string, string>, baseName: string, hash: string): string | undefined {
354
+ const ext = path.extname(baseName);
355
+ const stem = baseName.slice(0, baseName.length - ext.length).toLowerCase();
356
+ const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
357
+ const family = new RegExp("^" + esc(stem) + "(-[0-9a-z]{6}(_\\d+)?)?" + esc(ext.toLowerCase()) + "$");
358
+ const candidates = [...names.entries()].filter(([lower]) => family.test(lower)).map(([, real]) => real)
359
+ .sort((x, y) => x.length - y.length || (x < y ? -1 : 1)); // the plain name first, then the oldest-looking suffix
360
+ for (const name of candidates) {
361
+ try { if (sha1Hex(normalizeForCompare(name, fs.readFileSync(path.join(adir, name)))) === hash) return name; } catch { /* unreadable: not a match */ }
362
+ }
363
+ return undefined;
364
+ }
365
+
366
+ function shortHashOf(a: Asset, bytes: Buffer): string {
367
+ // Prefer the plugin's own contentHash (already normalised for SVG-export noise); fall back to a
368
+ // fresh sha1 of the bytes for an asset that somehow has no `.hash` (manifest-only / older plugin).
369
+ if (typeof a.hash === "string" && a.hash) return a.hash.replace(/[^a-z0-9]/gi, "").slice(0, 6);
370
+ return sha1Hex(bytes).slice(0, 6);
371
+ }
372
+
373
+ // Reuse `existingName`'s bytes for `a`: point `a.file` at it, and replace `a.text`/`a.base64` with the
374
+ // ACTUAL on-disk bytes so the manifest (writeScreenAssets, called right after this on the same array)
375
+ // hashes what is really there — a normalised-equal-but-byte-different re-pull must never record a hash
376
+ // for content that was never written (a "recorded hash matches nothing on disk", just
377
+ // introduced by a naive fix instead of closed by one).
378
+ function reuseExisting(a: Asset, dirPrefix: string, existingName: string, priorBytes: Buffer): void {
379
+ a.file = dirPrefix + "/" + existingName;
380
+ if (a.text != null) a.text = priorBytes.toString("utf8");
381
+ else if (a.base64 != null) a.base64 = priorBytes.toString("base64");
382
+ }
383
+
384
+ // The pointer the plugin puts in the tree for an asset: `assets/` + the file's base name. `Asset.file` itself
385
+ // is the BARE name (figma-plugin/src/assets.ts register() returns ASSET_DIR + file), and writeAssets turns
386
+ // it into `./<name>` or `<dir>/<name>` — so the base name is the one stable part to match on.
387
+ const assetPointer = (file: string): string => "assets/" + path.basename(file);
388
+
389
+ // The names writeAssets actually used, as tree pointers: `before` is each asset's `file` captured before
390
+ // the call (same order), so a content-reused or collision-suffixed asset maps
391
+ // `assets/<plugin name>` → `assets/<name on disk>`. Unchanged names are left out.
392
+ function renamedAssets(assets: Asset[] | null | undefined, before: readonly string[]): Map<string, string> {
393
+ const map = new Map<string, string>();
394
+ (assets || []).forEach((a, i) => {
395
+ const was = before[i];
396
+ if (was === undefined || !a || !a.file) return;
397
+ const from = assetPointer(was), to = assetPointer(a.file);
398
+ if (from !== to && !map.has(from)) map.set(from, to);
399
+ });
400
+ return map;
401
+ }
402
+
403
+ // Point every `asset`/`reference` string in a written tree (screen nodes, a page walk's layersDoc) at the
404
+ // file writeAssets really wrote. A deep walk over plain JSON, so it covers every node of every root and
405
+ // the root-level `reference` of a layer file alike. Returns how many pointers moved.
406
+ function rewireAssetPointers(tree: unknown, renamed: ReadonlyMap<string, string>): number {
407
+ if (!renamed.size) return 0;
408
+ let moved = 0;
409
+ const seen = new Set<object>();
410
+ const isObj = (x: unknown): x is Record<string, unknown> => !!x && typeof x === "object";
411
+ const visit = (v: unknown): void => {
412
+ if (!isObj(v) || seen.has(v)) return;
413
+ seen.add(v);
414
+ if (Array.isArray(v)) { for (const x of v) visit(x); return; }
415
+ const rec = v;
416
+ for (const k of Object.keys(rec)) {
417
+ const val = rec[k];
418
+ if ((k === "asset" || k === "reference") && typeof val === "string") {
419
+ const to = renamed.get(val);
420
+ if (to !== undefined) { rec[k] = to; moved++; }
421
+ } else visit(val);
422
+ }
423
+ };
424
+ visit(tree);
425
+ return moved;
426
+ }
427
+
428
+ // Every `asset`/`reference` pointer in `tree` that names no file under `dir` — checked by EXACT name (a
429
+ // case-insensitive disk would otherwise "find" a different-case file, which would ship the wrong
430
+ // chevron). A pull should never produce one; if it does, say so instead of leaving a silent hole.
431
+ // `exactExists(dir)` is that check on its own — also the hidden-reuse guard's (`fs.existsSync` would say
432
+ // yes to `assets/Angle-left.svg` when only `angle-left.svg`, a different icon, was on disk). One listing
433
+ // per directory, read on first use: call it after the pull's assets are written.
434
+ function exactExists(dir: string): (rel: string) => boolean {
435
+ const listings = new Map<string, Set<string>>();
436
+ return (rel: string): boolean => {
437
+ const d = path.join(dir, path.dirname(rel));
438
+ let names = listings.get(d);
439
+ if (!names) { try { names = new Set(fs.readdirSync(d)); } catch { names = new Set(); } listings.set(d, names); }
440
+ return names.has(path.basename(rel));
441
+ };
442
+ }
443
+ function danglingPointers(dir: string, tree: unknown): string[] {
444
+ const exists = exactExists(dir);
445
+ const out = new Set<string>();
446
+ const isObj = (x: unknown): x is Record<string, unknown> => !!x && typeof x === "object";
447
+ const visit = (v: unknown): void => {
448
+ if (!isObj(v)) return;
449
+ if (Array.isArray(v)) { for (const x of v) visit(x); return; }
450
+ for (const [k, val] of Object.entries(v)) {
451
+ if ((k === "asset" || k === "reference") && typeof val === "string") { if (!exists(val)) out.add(val); }
452
+ else visit(val);
453
+ }
454
+ };
455
+ visit(tree);
456
+ return [...out];
457
+ }
458
+
459
+ // An SVG with an embedded `<image>` and almost no paths is a PHOTO in an SVG shell — the rule is
460
+ // svg-normalize.ts isRasterShell, shared with the audit so both say the same thing about one file.
461
+ const countTag = (text: string, re: RegExp): number => (text.match(re) || []).length;
462
+ const PATH_TAG_RE = /<path\b/g;
463
+ const IMAGE_TAG_RE = /<image\b/g;
464
+
465
+ // `index` is the pull's PullIndex when the caller shares one (writeScreen — so assets/ is scanned at most once per
466
+ // pull, not once here and again for `duplicates`); it is updated with every file this call writes.
467
+ function writeAssets(dir: string, assets: Asset[] | null | undefined, log?: Log, subdir?: string, index?: PullIndex): number {
468
+ if (!assets || !assets.length) return 0;
469
+ const adir = path.join(dir, subdir || "assets");
470
+ fs.mkdirSync(adir, { recursive: true });
471
+ const existing = readExistingDirCaseFold(adir); // lower -> real name already on disk
472
+ const claimed = new Map<string, string>(); // lower -> real name this call has written/claimed so far (this batch)
473
+ for (const [k, v] of existing) claimed.set(k, v);
474
+ // Seeded from disk and updated as THIS call writes new content, so two assets in the SAME pull that
475
+ // happen to share content (not just two pulls) also collapse to one file.
476
+ const byContent = index || new PullIndex(adir);
477
+ const heavy: Array<{ file: string; node: string | undefined; bytes: number; paths: number; rasters: number }> = [];
478
+ let n = 0;
479
+ for (const a of assets) {
480
+ let bytes: Buffer;
481
+ if (a.text != null) bytes = Buffer.from(a.text, "utf8");
482
+ else if (a.base64 != null) bytes = Buffer.from(a.base64, "base64");
483
+ else continue; // manifest-only entry (e.g. an asset the plugin skipped) — nothing to write
484
+
485
+ let baseName = path.basename(a.file);
486
+ const dirPrefix = path.dirname(a.file); // usually "assets"
487
+
488
+ // CONTENT check FIRST, independent of what name this asset arrived under: a
489
+ // name-only check only ever catches "this pull picked the SAME name as something already there".
490
+ // It has no way to see that `Ellipse_2327-e9af26.svg`'s bytes are already on disk as
491
+ // `Ellipse_2327.svg`, or that a drifted `arrow-down-<hash>.svg` is already there under a different
492
+ // hash suffix from an earlier pull — which is exactly the shape a live three-screen pull produced
493
+ // (4 Ellipse_2327* files, 6 arrow-down* files, 3 angle-left* files for what should be 1/1/2 real
494
+ // icons) even with the name-only reuse path already in place.
495
+ const key = contentKey(baseName, bytes);
496
+ // A reference PNG belongs to ONE frame (the plugin never dedups it — assets.ts register()): two frames
497
+ // that render identically must keep their own `<id>_ref.png`, or both `reference`s would name one of
498
+ // them. So a reference reuses only a file of its OWN: its name, or a suffixed copy an earlier pull of
499
+ // the same frame wrote (`<id>_ref-<hash>[_N].png`, when the plain name held other bytes) — without the
500
+ // second, every re-pull over an old thumbnail added another `_1`, `_2`, … copy.
501
+ // That own-copy check stays EXACT bytes (a PNG; the tolerance is for SVG coordinates only).
502
+ const byContentName = a.kind === "reference" ? ownCopyWithContent(adir, claimed, baseName, sha1Hex(normalizeForCompare(baseName, bytes))) : byContent.get().findKey(key);
503
+ if (byContentName !== undefined) {
504
+ let priorBytes: Buffer | null = null;
505
+ try { priorBytes = fs.readFileSync(path.join(adir, byContentName)); } catch { /* fall through as new */ }
506
+ if (priorBytes) {
507
+ reuseExisting(a, dirPrefix, byContentName, priorBytes);
508
+ n++; // counted as written even though the on-disk file was left untouched
509
+ continue;
510
+ }
511
+ }
512
+
513
+ let lower = baseName.toLowerCase();
514
+ const priorName = claimed.get(lower);
515
+ if (priorName !== undefined) {
516
+ // Reaching here means the CONTENT check above already ruled out "this is the same asset under
517
+ // any name" — so a name collision at this point is a same-name (mod case) DIFFERENT asset, full
518
+ // stop. It never fires for a content-duplicate, which is also what keeps the `_1` guard from
519
+ // producing `Ellipse_2327-51bd18_1.svg`: a genuinely identical asset reaching this branch with a
520
+ // hash-suffixed name that ALSO already existed (both suffix attempts being the exact same 6 hex
521
+ // chars, since both were hashes of the SAME content) would make the guard's `_N` mask a
522
+ // content-dedup failure instead of resolving a real 6-hex collision. With content checked first,
523
+ // the guard can only fire on a genuine collision between two DIFFERENT assets' hash suffixes,
524
+ // which is the vanishingly-unlikely case it is for.
525
+ const ext = path.extname(baseName);
526
+ const stem = baseName.slice(0, baseName.length - ext.length);
527
+ baseName = stem + "-" + shortHashOf(a, bytes) + ext;
528
+ lower = baseName.toLowerCase();
529
+ let guard = 0;
530
+ while (claimed.has(lower) && guard++ < 5) { baseName = stem + "-" + shortHashOf(a, bytes) + "_" + guard + ext; lower = baseName.toLowerCase(); }
531
+ }
532
+ const file = path.join(adir, baseName);
533
+ fs.writeFileSync(file, bytes);
534
+ claimed.set(lower, baseName);
535
+ byContent.add(baseName, key); // so a LATER asset in this same call also dedups against it
536
+ a.file = dirPrefix + "/" + baseName; // downstream (writeScreenAssets, index entries) reads this
537
+ const paths = a.text != null ? countTag(a.text, PATH_TAG_RE) : 0;
538
+ const rasters = a.text != null ? countTag(a.text, IMAGE_TAG_RE) : 0;
539
+ // The whole-frame reference PNG (a.kind === "reference") is never inlined or shipped
540
+ // — writeScreenAssets already excludes it from `count`/`totalBytes` and its own `heavy` list for
541
+ // exactly this reason (see there). This pull-time warning must agree: a "20174_143363_
542
+ // ref.png 0.25 MB is too heavy to inline" is pure noise about a file the app never inlines.
543
+ if (a.kind !== "reference" && (bytes.length >= BIG_ASSET_BYTES || paths >= BUSY_SVG_PATHS)) heavy.push({ file: baseName, node: a.id, bytes: bytes.length, paths, rasters });
544
+ n++;
545
+ }
546
+ if (log) log("wrote " + n + " asset(s) to " + adir);
547
+ heavy.sort((x, y) => y.bytes - x.bytes);
548
+ const shells = heavy.filter((h) => isRasterShell(h.rasters, h.paths));
549
+ const vectors = heavy.filter((h) => !shells.includes(h));
550
+ if (log) for (const h of shells) {
551
+ log(
552
+ `warn ${h.file} ${(h.bytes / 1048576).toFixed(2)} MB is a raster image embedded in an SVG shell — use it as an image ` +
553
+ "(`<img src>`/URL import), or ask the designer for a PNG/JPG export of that layer; do not inline it"
554
+ );
555
+ }
556
+ if (log && vectors.length) {
557
+ log(
558
+ "warn " + vectors.length + " asset(s) are too heavy to inline — " +
559
+ vectors.slice(0, 3).map((h) => `${h.file} ${(h.bytes / 1048576).toFixed(2)} MB${h.paths ? ` / ${h.paths} <path> elements` : ""}`).join(", ") +
560
+ (vectors.length > 3 ? ", …" : "")
561
+ );
562
+ log(
563
+ " A flattened texture or illustration exports as thousands of vector paths. Inlining one of these " +
564
+ "can dominate your whole bundle. Import it by URL, or ask the designer to re-export it as a PNG — do not redraw or simplify it yourself."
565
+ );
566
+ }
567
+ return n;
568
+ }
569
+
570
+ // `assetsGeometry` counts graphics Figma's exporter returned no SVG for, recovered as raw path data
571
+ // instead (figma-plugin/src/assets.ts geometryOf) — usable, but a strictly worse asset than a real export.
572
+ // A real pull had 40% of a screen's nodes fall back this way with `manifest.warnings: []` and no pull-time
573
+ // signal at all. Warning only at a share of nodes, or blaming "unusual paint/blend", misses the cause: on
574
+ // the real exports every one of those fallbacks was a HIDDEN node, which the plugin does not export at
575
+ // all (`assetSkipped:"hidden"`), or an icon container's own background rectangle, which is a
576
+ // failure instead. What is left is a real degraded vector, so ANY fallback warns, and the line
577
+ // names up to five of them, `name (id)`, read from the tree as written. Exported for writeScreen and tests.
578
+ const GEOMETRY_WARN_LIST = 5;
579
+ function assetsGeometryWarning(manifest: Partial<Manifest> | null | undefined, tree?: unknown): string | null {
580
+ const nodes = manifest && typeof manifest.nodes === "number" ? manifest.nodes : 0;
581
+ const geo = manifest && typeof manifest.assetsGeometry === "number" ? manifest.assetsGeometry : 0;
582
+ if (!geo) return null;
583
+ const named: string[] = [];
584
+ let found = 0;
585
+ const visit = (v: unknown): void => {
586
+ if (!v || typeof v !== "object") return;
587
+ if (Array.isArray(v)) { for (const x of v) visit(x); return; }
588
+ const rec: Record<string, unknown> = Object.fromEntries(Object.entries(v));
589
+ if (rec.geometry && typeof rec.geometry === "object") {
590
+ found++;
591
+ if (named.length < GEOMETRY_WARN_LIST) named.push(`${typeof rec.name === "string" ? rec.name : "?"} (${typeof rec.id === "string" ? rec.id : "?"})`);
592
+ }
593
+ visit(rec.children);
594
+ };
595
+ visit(tree);
596
+ return `warn ${geo}${nodes ? ` of ${nodes}` : ""} node(s) fell back to raw geometry — Figma's exporter returned no SVG for these ` +
597
+ "visible vectors, so their outlines were inlined as `geometry`; check them against the reference" +
598
+ (named.length ? ": " + named.join(", ") + (found > named.length || geo > named.length ? ", …" : "") : "");
599
+ }
600
+
601
+ // The hidden graphics the plugin did not export, and how many the bridge pointed at a visible
602
+ // twin's file. One info line, only when the manifest counted any (an older export has no counter).
603
+ function assetsHiddenLine(manifest: Partial<Manifest> | null | undefined, reuse?: ReuseResult): string | null {
604
+ const n = manifest && typeof manifest.assetsHidden === "number" ? manifest.assetsHidden : 0;
605
+ if (!n) return null;
606
+ const m = reuse ? reuse.reused : 0;
607
+ return `info ${n} hidden graphic(s) not exported (\`assetSkipped:"hidden"\`); ${m} reuse a visible twin's file` +
608
+ (reuse && reuse.crossScreen ? ` (${reuse.crossScreen} from another screen)` : "");
609
+ }
610
+
611
+ // The hidden nodes the tree keeps — hidden themselves (`hidden:true`) or under a hidden ancestor (no flag
612
+ // of their own; the plugin counts both) — designed states a builder wires up, not noise to drop. One
613
+ // info line, only when the manifest counted any (an older export has no counter).
614
+ function hiddenNodesLine(manifest: Partial<Manifest> | null | undefined): string | null {
615
+ const n = manifest && typeof manifest.hiddenNodes === "number" ? manifest.hiddenNodes : 0;
616
+ return n ? `info ${n} node(s) hidden (themselves or under a hidden ancestor) kept in the tree (conditional UI)` : null;
617
+ }
618
+
619
+ // Reference PNGs land in assets/, the same place a --node pull puts the frame's own reference.
620
+ //
621
+ // They do not get a screenshots/ subdir of their own. The case for one — a discovery screenshot is not a
622
+ // shipped asset — does not survive practice: a separate subdir produced three different answers to
623
+ // "where is the PNG?" — the skill said screenshots/, `dtwin help` said assets/, the success message
624
+ // and the `reference` field both printed assets/ while the byte landed in screenshots/ — and when
625
+ // the node was later pulled, the pull wrote a byte-identical copy into assets/ anyway. One
626
+ // directory, one name, no duplicate: shoot a node during discovery and the PNG is already in the
627
+ // place build-screen reads, whether or not you go on to pull it.
628
+ const REF_DIR = "assets";
629
+ //
630
+ // An explicit `scale` (a discovery thumbnail, e.g. 0.25) is NOT the frame's reference: it gets its own
631
+ // name, `<id>_shot@<scale>x.png`, so it never takes the `<id>_ref.png` slot a later pull's full-size
632
+ // reference is written to (otherwise a 360 px thumbnail holds the slot, the 2048 px export is suffixed, and
633
+ // the index points at the thumbnail). The default scale renders exactly what a pull would, so it keeps
634
+ // the shared name and a later pull reuses the same file.
635
+ //
636
+ // `png`: the reference PNG's own pixel size, read from its bytes — absent when no reference asset
637
+ // carried PNG bytes.
638
+ // `opts.keepPrev`: a change report, like the other writers' — always empty: writeAssets never replaces a file with
639
+ // different bytes, so a screenshot has nothing to keep.
640
+ function writeScreenshot(outDir: string | null | undefined, r: ScreenshotReply, log?: Log, opts?: { scale?: number | undefined; keepPrev?: boolean | undefined }): { outDir: string; reference: string; png?: { w: number; h: number }; wrote: { screenshot: true; assets: number; reference: string }; changes?: ChangeReport } {
641
+ const dir = resolveOutDir(outDir);
642
+ fs.mkdirSync(dir, { recursive: true });
643
+ const scale = opts && typeof opts.scale === "number" && opts.scale > 0 ? opts.scale : undefined;
644
+ if (scale !== undefined) for (const a of r.assets) {
645
+ if (a && a.kind === "reference" && a.file) a.file = path.dirname(a.file) + "/" + path.basename(a.file).replace(/_ref\.png$/i, "").replace(/\.png$/i, "") + `_shot@${Number(scale.toPrecision(4))}x.png`;
646
+ }
647
+ const refAsset = r.assets.find((a) => a && a.kind === "reference" && a.base64 != null);
648
+ const png = refAsset && refAsset.base64 != null ? pngSize(Buffer.from(refAsset.base64, "base64")) : null;
649
+ const assets = writeAssets(dir, r.assets, log, REF_DIR);
650
+ // The path the caller should PRINT. `r.reference` is the plugin's own relative name; recomputing it
651
+ // here from the file actually written is what keeps the message and the file in agreement.
652
+ const first = r.assets.find((a) => a && a.file);
653
+ const reference = first ? REF_DIR + "/" + path.basename(first.file) : r.reference;
654
+ return { outDir: dir, reference, ...(png ? { png } : {}), wrote: { screenshot: true as const, assets, reference }, ...(opts && opts.keepPrev ? { changes: { kept: [], replaced: [], earlier: [] } } : {}) };
655
+ }
656
+
657
+ // The whole-export write, shared by the CLI's default path and the MCP export tools' writeToDisk
658
+ // path. Returns the COMPACT INDEX — counts and paths, never node payloads — which is precisely what
659
+ // an MCP tool should hand back in place of the export itself: the agent then Reads/Greps the files
660
+ // at whatever granularity it actually needs, instead of paying for the whole tree in context.
661
+ // `r` is a full/page pull (designSystem + layersDoc + assets) or a catalog-only one (designSystem alone).
662
+ // `opts.keepPrev` (the MCP server's implicit spill only): every JSON under pages/, design-system/ or
663
+ // libraries/ this write replaces with DIFFERENT content (exportedAt/generatedAt ignored) is first copied to
664
+ // `<file>.prev` — one level, the next such spill replaces it — and `wrote.prevKept` lists those .prev
665
+ // paths (absolute), present only when non-empty. The CLI and an explicit writeToDisk keep nothing.
666
+ // Assets need nothing: writeAssets never overwrites a file with different bytes.
667
+ // Under keepPrev the result also carries `changes` (ChangeReport): those .prev paths, every pages/ layer file that was
668
+ // on disk before this write, and the .prev files earlier spills left (prevFilesUnder) — for the spill's note.
669
+ function writeExport(outDir: string | null | undefined, r: Stamped<FullExportReply | DesignSystemReply>, log?: Log, opts?: { keepPrev?: boolean | undefined }) {
670
+ const dir = resolveOutDir(outDir);
671
+ const keep: string[] | undefined = opts && opts.keepPrev ? [] : undefined;
672
+ const replaced: string[] = [];
673
+ const kept = (): { prevKept?: string[] } => (keep && keep.length ? { prevKept: keep } : {});
674
+ // Read once the write is done: the .prev files on disk that this write did not make.
675
+ const changes = (): { changes?: ChangeReport } => {
676
+ if (!keep) return {};
677
+ const made = new Set(keep);
678
+ return { changes: { kept: [...keep], replaced: [...replaced], earlier: prevFilesUnder(dir).filter((f) => !made.has(f)) } };
679
+ };
680
+ fs.mkdirSync(dir, { recursive: true });
681
+ writeSchemaDoc(dir, log);
682
+ // A library catalog is routed by the PRODUCER's own flag (`source.role`), not by a CLI-side guess:
683
+ // the plugin is the only thing that knows which file it ran in, and misrouting would overwrite the
684
+ // design file's catalog with a library's.
685
+ const isLib = !!(r.designSystem.source && r.designSystem.source.role === "library");
686
+ if (isLib) {
687
+ const lib = writeLibrary(dir, r.designSystem, log, keep);
688
+ return { outDir: dir, wrote: { library: lib.dir, libraryCounts: lib.counts, publish: lib.publish, orphans: lib.orphans, ...kept() }, ...changes() };
689
+ }
690
+ const ds = writeDesignSystem(dir, r.designSystem, log, keep);
691
+ // On a file that CONSUMES a library, the variables flagged `remote` are only the library ones something
692
+ // here references — not that library's catalog. Say so while the pull's log is being read.
693
+ const vars = r.designSystem.variables || [];
694
+ const remoteVars = vars.filter((v) => v && v.remote === true).length;
695
+ if (log && remoteVars) log(`info ${remoteVars} of ${vars.length} variables come from a library this file consumes — not that library's catalog: open the library file and run \`dtwin pull --as-library "<name>"\` (CLI) to pull the catalog itself`);
696
+ // The page walk half, present on a full pull only — narrowed on the field that IS the walk.
697
+ const full: Stamped<FullExportReply> | null = "layersDoc" in r ? r : null;
698
+ // figma-pull.ts resolves which connected Figma file this pull talked to and stamps it as
699
+ // `r.sourceFile`/`r.sourceFileKey` (the plugin itself has no reason to know its own bridge-side
700
+ // connection id). Forward it onto layersDoc so buildPageLayout can carry it onto every per-page
701
+ // layer file/index row — the same field writeScreen below stamps for a single-screen pull.
702
+ if (full && full.sourceFile && full.layersDoc.sourceFile === undefined) {
703
+ full.layersDoc.sourceFile = full.sourceFile;
704
+ if (full.sourceFileKey) full.layersDoc.sourceFileKey = full.sourceFileKey;
705
+ }
706
+ // Assets FIRST, then the layer files: writeAssets may reuse or rename a file, and the trees must name
707
+ // what it actually wrote.
708
+ const before = full ? full.assets.map((a) => a.file) : [];
709
+ const assets = full ? writeAssets(dir, full.assets, log) : 0;
710
+ if (full) {
711
+ const moved = rewireAssetPointers(full.layersDoc, renamedAssets(full.assets, before));
712
+ if (log && moved) log(`rewired ${moved} asset pointer(s) to the names written (content-reused or collision-renamed files)`);
713
+ // A page walk reuses a visible twin within its own walk only (no per-screen .assets.json to read).
714
+ const roots = (full.layersDoc.layers || []).map((l) => l.tree).filter((t): t is IrNode => !!t && typeof t === "object");
715
+ const reuse = reuseHiddenAssets(roots, { pointerExists: exactExists(dir) });
716
+ if (log && reuse.hidden) log(`info ${reuse.hidden} hidden graphic(s) not exported (\`assetSkipped:"hidden"\`); ${reuse.reused} reuse a visible twin's file`);
717
+ const hn = hiddenNodesLine(full.layersDoc.manifest);
718
+ if (log && hn) log(hn);
719
+ }
720
+ const pages = full ? writePages(dir, full.layersDoc, log, keep, keep ? replaced : undefined) : null;
721
+ if (full && log) { const d = danglingPointers(dir, full.layersDoc); if (d.length) log(`warn ${d.length} asset pointer(s) name a file that is not on disk: ${d.slice(0, 3).join(", ")}${d.length > 3 ? ", …" : ""}`); }
722
+ return {
723
+ outDir: dir,
724
+ wrote: {
725
+ designSystem: true,
726
+ designSystemCounts: ds,
727
+ layerFiles: pages ? pages.layerFiles : 0,
728
+ pageDirs: pages ? pages.pageDirs : 0,
729
+ assets,
730
+ assetsSkipped: full ? full.assets.length - assets : 0,
731
+ ...kept(),
732
+ },
733
+ index: pages ? pages.meta : undefined,
734
+ ...changes(),
735
+ };
736
+ }
737
+
738
+ // Every `*.prev` an implicit full/design-system spill can keep: under pages/, design-system/ and libraries/, plus the
739
+ // export ROOT's own (design-system.json.prev — the manifest sits at the root, not in a tree). A screen spill's
740
+ // <screen>.json.prev sits under pages/ too, so it is among them.
741
+ function prevFilesUnder(dir: string): string[] {
742
+ const out: string[] = [];
743
+ const walk = (d: string): void => {
744
+ let ents: fs.Dirent[] = [];
745
+ try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
746
+ for (const e of ents) {
747
+ const f = path.join(d, e.name);
748
+ if (e.isDirectory()) walk(f);
749
+ else if (e.name.endsWith(".prev")) out.push(f);
750
+ }
751
+ };
752
+ for (const sub of ["pages", "design-system", "libraries"]) walk(path.join(dir, sub));
753
+ let root: fs.Dirent[] = [];
754
+ try { root = fs.readdirSync(dir, { withFileTypes: true }); } catch { /* no export dir yet */ }
755
+ for (const e of root) if (e.isFile() && e.name.endsWith(".prev")) out.push(path.join(dir, e.name));
756
+ return out;
757
+ }
758
+
759
+ // The `reuseKey`s of a <base>.assets.json's rows whose owner carries no paint override — all otherScreenTrees
760
+ // needs from it. Remembered per file while it is unchanged (file-memo.ts), so a process pulling screen after
761
+ // screen into a project of hundreds re-reads only the ones that changed; an unreadable or malformed file has none.
762
+ // resetScreenReuseKeys starts it afresh, with a given clock in the tests (a file is remembered only once it is 2 s old).
763
+ let screenReuseKeys = new FileMemo<ReadonlySet<string>>();
764
+ function resetScreenReuseKeys(o: FileMemoOptions = {}): void { screenReuseKeys = new FileMemo<ReadonlySet<string>>(o); }
765
+ function reuseKeysOf(file: string): ReadonlySet<string> {
766
+ return screenReuseKeys.get(file, () => {
767
+ const doc = readJsonOr(file, null);
768
+ const rows: unknown[] = doc && typeof doc === "object" && "files" in doc && Array.isArray(doc.files) ? doc.files : [];
769
+ const out = new Set<string>();
770
+ for (const row of rows) {
771
+ if (!row || typeof row !== "object") continue;
772
+ const key = "reuseKey" in row ? row.reuseKey : undefined;
773
+ const owner = "owner" in row ? row.owner : undefined;
774
+ const blocked = !!owner && typeof owner === "object" && "paintOverrides" in owner && owner.paintOverrides === true;
775
+ if (typeof key === "string" && !blocked) out.add(key);
776
+ }
777
+ return out;
778
+ }) ?? new Set<string>();
779
+ }
780
+
781
+ // The other screens' trees that may hold a visible twin for one of `keys`: every pages/**/<base>.assets.json
782
+ // in this export (except this screen's own) with a row whose `reuseKey` is wanted and whose owner carries no
783
+ // paint override, then its sibling <base>.json — read only for those, and re-checked there by
784
+ // reuseHiddenAssets (size, transform, overrides), since one row's key says nothing about the rest. A sibling
785
+ // whose roots include one of `ownRoots` (this pull's root ids) is this frame's own stale JSON — skipped.
786
+ function otherScreenTrees(dir: string, ownAssets: string, keys: ReadonlySet<string>, ownRoots: ReadonlySet<string> = new Set()): IrNode[][] {
787
+ const out: IrNode[][] = [];
788
+ const own = path.join(dir, ownAssets);
789
+ const files: string[] = [];
790
+ const walk = (d: string): void => {
791
+ let ents: fs.Dirent[] = [];
792
+ try { ents = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
793
+ for (const e of ents.sort((x, y) => (x.name < y.name ? -1 : x.name > y.name ? 1 : 0))) {
794
+ const p = path.join(d, e.name);
795
+ if (e.isDirectory()) walk(p);
796
+ else if (e.name.endsWith(".assets.json") && p !== own) files.push(p);
797
+ }
798
+ };
799
+ walk(path.join(dir, "pages"));
800
+ for (const f of files) {
801
+ const has = reuseKeysOf(f);
802
+ let wanted = false;
803
+ for (const k of keys) if (has.has(k)) { wanted = true; break; }
804
+ if (!wanted) continue;
805
+ const screen = readJsonOr(f.slice(0, -".assets.json".length) + ".json", null);
806
+ const nodes = screen && typeof screen === "object" && "nodes" in screen && Array.isArray(screen.nodes) ? screen.nodes : null;
807
+ // The same frame under an older name or page (renamed): its stale JSON is no twin of itself.
808
+ const roots = nodes ? nodes.filter((n: unknown): n is IrNode => !!n && typeof n === "object") : [];
809
+ if (roots.some((n) => ownRoots.has(n.id))) continue;
810
+ if (roots.length) out.push(roots);
811
+ }
812
+ return out;
813
+ }
814
+
815
+ // The selection/single-node export has a different SHAPE than the full one — one `screen` tree plus
816
+ // `variables`, no designSystem/layersDoc — so it gets its own writer rather than a branch inside
817
+ // writeExport.
818
+ //
819
+ // It files into the SAME pages/ tree a --page pull writes: pages/<Page>/<Name>__<id>.json, with the
820
+ // pull's token slice and its asset index as siblings. There is no flat design/<Screen>.json: naming
821
+ // files from the layer alone makes two frames called "Popup" overwrite each other, and leaves a
822
+ // --node project with no index, no page context and nothing sync-design or verify could address
823
+ // Screens ARRIVE one pull at a time, so both indexes merge with what is
824
+ // already on disk rather than being recomputed in one pass like buildPageLayout's.
825
+ //
826
+ // Asset filenames are still NOT sanitised here: the plugin owns them (see writeAssets).
827
+ //
828
+ // `opts.keepPrev`: when set and the screen JSON already exists with a DIFFERENT design (the top-level
829
+ // `exportedAt` stamp aside), the old file is
830
+ // kept as `<screen>.json.prev` (one level, replaced by the next one) and `wrote.prev` names it. The MCP server
831
+ // sets it only on an implicit spill — an explicit pull overwriting its own file is the point of the pull. Under it the
832
+ // result also carries `changes` (ChangeReport): that .prev, or — when this write kept none — the one an earlier spill left.
833
+ function writeScreen(outDir: string | null | undefined, r: Stamped<ScreenReply>, log?: Log, opts?: { keepPrev?: boolean | undefined }) {
834
+ const dir = resolveOutDir(outDir);
835
+ const paths = screenPaths(r, "/");
836
+ fs.mkdirSync(path.join(dir, "pages", paths.dir), { recursive: true });
837
+ writeSchemaDoc(dir, log);
838
+
839
+ // Stamp which Figma file this pull actually talked to, straight onto the screen doc's own
840
+ // top level — the ONE thing doctor.ts's exportSourceCounts() can read without opening a second file, and
841
+ // the reason a project pulled before this field existed is told apart from one whose source is
842
+ // simply unknown (undefined, never a guess). figma-pull.ts resolves it; this is just where it lands.
843
+ // Assets FIRST: writeAssets reuses a byte-identical file already on disk under another name, and
844
+ // version-suffixes a same-name (or same-name-but-case) different file. The plugin built every node's
845
+ // `asset` and the root's `reference` from ITS names, so they are rewired to the names written before the
846
+ // screen JSON is — otherwise nodes point at files that were never written, or (case-insensitive disk) at
847
+ // a different icon, and the index's `reference` at whatever thumbnail held the plain name.
848
+ const before = (r.assets || []).map((a) => a.file);
849
+ const contentIndex = new PullIndex(path.join(dir, "assets"));
850
+ const assets = writeAssets(dir, r.assets, log, undefined, contentIndex);
851
+ const moved = rewireAssetPointers(r.screen.nodes, renamedAssets(r.assets, before));
852
+ if (log && moved) log(`rewired ${moved} asset pointer(s) to the names written (content-reused or collision-renamed files)`);
853
+ // A hidden graphic the plugin did not export points at a visible twin's file — this pull's tree
854
+ // first, then the screens already on disk (their .assets.json `reuseKey`, re-checked on their tree).
855
+ const reuse = reuseHiddenAssets(r.screen.nodes, {
856
+ otherTrees: (keys) => otherScreenTrees(dir, paths.assets, keys, new Set((r.screen.nodes || []).map((n) => n.id))),
857
+ pointerExists: exactExists(dir),
858
+ });
859
+ const screenDoc = r.sourceFile
860
+ ? Object.assign({}, r.screen, { sourceFile: r.sourceFile }, r.sourceFileKey ? { sourceFileKey: r.sourceFileKey } : {})
861
+ : r.screen;
862
+ // writeJson serialises once and keeps the replaced file through `keep`; quiet, so the kept line can come before the wrote line
863
+ const keep: string[] | undefined = opts && opts.keepPrev ? [] : undefined;
864
+ writeJson(dir, paths.screen, screenDoc, true, undefined, keep);
865
+ const prev = keep && keep[0];
866
+ if (log) {
867
+ if (prev) log(`kept the previous ${paths.screen} as ${path.basename(prev)} (it differed from this export)`);
868
+ log("wrote " + path.join(dir, paths.screen));
869
+ }
870
+ if (log) { const d = danglingPointers(dir, r.screen.nodes); if (d.length) log(`warn ${d.length} asset pointer(s) name a file that is not on disk: ${d.slice(0, 3).join(", ")}${d.length > 3 ? ", …" : ""}`); }
871
+ // `variables` is always on a real screen reply; a hand-built one (the tests) may omit it.
872
+ const variables = r.variables ? writeScreenVariables(dir, paths, r.variables, log) : null;
873
+ const assetIndex = writeScreenAssets(dir, paths, r.assets, r.screen.nodes, contentIndex);
874
+ // A screen whose icons largely fell back to raw geometry
875
+ // (figma-plugin/src/assets.ts geometryOf) instead of a real SVG export gets a warning AT PULL TIME,
876
+ // not only if someone happens to go looking in manifest.assetsGeometry later.
877
+ if (log) {
878
+ const gw = assetsGeometryWarning(r.screen.manifest, r.screen.nodes);
879
+ if (gw) log(gw);
880
+ const hl = assetsHiddenLine(r.screen.manifest, reuse);
881
+ if (hl) log(hl);
882
+ const hn = hiddenNodesLine(r.screen.manifest);
883
+ if (hn) log(hn);
884
+ }
885
+
886
+ // The entry a consumer reads instead of guessing filenames. Every sibling this pull produced is a
887
+ // POINTER here, for the same reason buildPageLayout emits real relative paths: a consumer that
888
+ // reassembles `pages/<dir>/<base>.vars.json` from parts is right until the day one part changes.
889
+ const root: Partial<IrNode> = (Array.isArray(r.screen.nodes) && r.screen.nodes[0]) || {};
890
+ // title/texts: the visible title a user types is a TEXT node inside the
891
+ // frame, not the Figma layer name (`root.name`/`entry.name` below); see pages-layout.ts deriveTitle.
892
+ const title = deriveTitle(root);
893
+ // How the reference PNG maps onto the design's coordinates. The plugin renders it at an auto scale
894
+ // over the node's RENDER bounds (shadows, outside strokes), so a popup with a 21 px shadow is cropped
895
+ // differently from its box. referenceScale = PNG px per design px; referenceOffset = where the PNG's
896
+ // top-left sits relative to the box's top-left (design px, usually <= 0).
897
+ const refAsset = (r.assets || []).find((a) => a && a.kind === "reference" && a.file && assetPointer(a.file) === root.reference);
898
+ const refSize = refAsset && refAsset.base64 != null ? pngSize(Buffer.from(refAsset.base64, "base64")) : null;
899
+ const refBox = root.renderBox || root.box;
900
+ const referenceScale = refSize && refBox && refBox.w ? Math.round((refSize.w / refBox.w) * 10000) / 10000 : undefined;
901
+ const referenceOffset = referenceScale !== undefined && root.box && typeof root.box.x === "number" && typeof root.box.y === "number"
902
+ ? (root.renderBox ? { x: root.renderBox.x - root.box.x, y: root.renderBox.y - root.box.y } : { x: 0, y: 0 })
903
+ : undefined;
904
+ const texts = collectTexts(root);
905
+ // The index row's join keys. A screen with no id at all (no nodeId on the reply, no root node) could
906
+ // only be indexed as a row nothing can ever address again — refuse it, loudly, rather than write it.
907
+ const name = r.screen.screen || r.screenName;
908
+ const id = paths.nodeId || root.id;
909
+ if (!id) throw new Error(`screen '${name}' has no id — cannot index it (the reply carried no nodeId and no root node)`);
910
+ const entry: ScreenIndexRow = {
911
+ name,
912
+ id,
913
+ ...ifDefined("type", root.type),
914
+ page: paths.page,
915
+ pageId: paths.pageId,
916
+ title,
917
+ texts,
918
+ ...ifDefined("exportedAt", r.screen && r.screen.exportedAt),
919
+ ...ifDefined("sourceFile", r.sourceFile || undefined),
920
+ ...ifDefined("sourceFileKey", (r.sourceFile && r.sourceFileKey) || undefined),
921
+ file: paths.screen,
922
+ ...ifDefined("variables", r.variables ? paths.variables : undefined),
923
+ ...ifDefined("assets", assetIndex ? paths.assets : undefined),
924
+ ...ifDefined("reference", root.reference),
925
+ ...ifDefined("referenceScale", referenceScale),
926
+ ...ifDefined("referenceOffset", referenceOffset),
927
+ ...ifDefined("nodes", (r.screen.manifest && r.screen.manifest.nodes) || undefined),
928
+ ...ifDefined("w", root.box && root.box.w),
929
+ ...ifDefined("h", root.box && root.box.h),
930
+ };
931
+ const pageIndex = mergeScreenIndex(readJsonOr(path.join(dir, paths.index), null), Object.assign({}, entry, { page: paths.page, pageId: paths.pageId }));
932
+ writeJson(dir, paths.index, pageIndex, true);
933
+ writeJson(dir, paths.rootIndex, mergeRootIndex(readJsonOr(path.join(dir, paths.rootIndex), null), paths, pageIndex.layers.length, entry), true);
934
+ if (log) log(`indexed ${paths.screen} in ${paths.rootIndex} (page '${paths.page || "unfiled"}' now holds ${pageIndex.layers.length} screen(s))`);
935
+
936
+ return {
937
+ outDir: dir,
938
+ wrote: {
939
+ screen: path.join(dir, paths.screen),
940
+ ...ifDefined<"prev", string>("prev", prev),
941
+ page: paths.page,
942
+ index: path.join(dir, paths.rootIndex),
943
+ variables: !!r.variables,
944
+ variablesMerge: variables || undefined,
945
+ assets,
946
+ assetIndex: assetIndex || undefined,
947
+ assetsSkipped: Array.isArray(r.assets) ? r.assets.length - assets : 0,
948
+ },
949
+ ...(keep ? { changes: { kept: [...keep], replaced: [], earlier: !prev && fs.existsSync(path.join(dir, paths.screen) + ".prev") ? [path.join(dir, paths.screen) + ".prev"] : [] } } : {}),
950
+ };
951
+ }
952
+
953
+ // Copy `file` to `<file>.prev` before it is replaced, when it exists and differs from `next` (the
954
+ // bytes about to be written). The `.prev` suffix is not `*.json`, so no screen glob, index or snapshot reader
955
+ // picks it up. A copy, not a rename: the target stays intact until writeJson's own atomic rename replaces it.
956
+ // Returns the .prev path, or undefined when nothing was kept (no file, identical, or unreadable).
957
+ function keepPrevCopy(file: string, next: string): string | undefined {
958
+ let old: string;
959
+ try { old = fs.readFileSync(file, "utf8"); } catch { return undefined; }
960
+ if (old === next) return undefined;
961
+ // The plugin stamps a fresh exportedAt on every reply, so compare the documents without it: an unchanged
962
+ // design re-spilled must not replace the .prev that holds what the FIRST spill displaced.
963
+ const a = unstampedJson(old);
964
+ if (a !== null && a === unstampedJson(next)) return undefined;
965
+ const prev = file + ".prev";
966
+ fs.copyFileSync(file, prev);
967
+ return prev;
968
+ }
969
+
970
+ // A JSON document re-serialised without its stamps, or null when it is not a JSON object: the top-level
971
+ // exportedAt/generatedAt, and the per-library exportedAt on libraries/index.json's rows. That index carries
972
+ // generatedAt plus one stamp per row (library-layout.ts mergeLibrariesIndex), so without these every re-spill
973
+ // of an unchanged library would keep a pointless .prev.
974
+ function withoutExportedAt(x: unknown): unknown {
975
+ if (!isRecord(x)) return x;
976
+ const { exportedAt: _drop, ...rest } = x;
977
+ void _drop;
978
+ return rest;
979
+ }
980
+ function unstampedJson(s: string): string | null {
981
+ let o: unknown;
982
+ try { o = JSON.parse(s) as unknown; } catch { return null; }
983
+ if (!isRecord(o)) return null;
984
+ const { exportedAt: _drop, generatedAt: _gen, ...rest } = o;
985
+ void _drop;
986
+ void _gen;
987
+ if (Array.isArray(rest.libraries)) rest.libraries = rest.libraries.map(withoutExportedAt);
988
+ return JSON.stringify(rest);
989
+ }
990
+
991
+ // Which colours an exported SVG hard-codes.
992
+ //
993
+ // Figma exports the frame as it LOOKS, so a Dark-mode icon arrives with `stroke="#D4D4D4"` baked in
994
+ // and the same file cannot serve a Light theme. Editing the asset is the wrong fix
995
+ // — the producer owns these files and a re-pull overwrites them — so the right move is to swap the
996
+ // colour for `currentColor` at RENDER time. But that is only safe for a glyph whose colour is purely
997
+ // thematic: a red trash icon and a green tick carry meaning, and recolouring those is a bug.
998
+ //
999
+ // One colour throughout = thematic, safe to recolour. More than one = semantic, leave it alone. The
1000
+ // builder gets the answer per file instead of having to open each SVG.
1001
+ function svgPalette(text: unknown): { colors: string[]; monochrome: boolean; paths: number; embeddedRaster?: number } {
1002
+ const colors = new Set<string>();
1003
+ for (const m of String(text).matchAll(/(?:fill|stroke)\s*=\s*"([^"]+)"/g)) {
1004
+ const raw = m[1];
1005
+ if (raw === undefined) continue; // cannot happen: group 1 is not optional
1006
+ const v = raw.trim().toLowerCase();
1007
+ if (v === "none" || v === "transparent" || v === "currentcolor" || v.startsWith("url(")) continue;
1008
+ colors.add(v);
1009
+ }
1010
+ const paths = countTag(String(text), PATH_TAG_RE);
1011
+ // An `<image>` inside the SVG (a photo in a vector shell) — only written when there is one.
1012
+ const rasters = countTag(String(text), IMAGE_TAG_RE);
1013
+ return rasters ? { colors: [...colors], monochrome: colors.size === 1, paths, embeddedRaster: rasters } : { colors: [...colors], monochrome: colors.size === 1, paths };
1014
+ }
1015
+
1016
+ function readJsonOr(file: string, fallback: unknown): unknown {
1017
+ try {
1018
+ return JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
1019
+ } catch {
1020
+ return fallback; // absent or corrupt — this pull is the first, or the last one was interrupted
1021
+ }
1022
+ }
1023
+
1024
+ // Which assets belong to THIS screen, and which of them are byte-identical to each other.
1025
+ //
1026
+ // design/assets/ is flat, shared and cumulative: after three pulls it held 98 files, and the only way
1027
+ // to answer "which of these does THIS screen use" is to walk the screen JSON.
1028
+ // This index answers it directly, and the content hash makes the duplication visible — the same
1029
+ // sidebar icon exported once per instance path shows up as several names under one hash, so a
1030
+ // consumer can import one file instead of five without diffing bytes itself.
1031
+ // `duplicates` is computed over the WHOLE shared assets/ directory — not just this pull's own array.
1032
+ // `assets/` is explicitly shared and cumulative (see writeAssets' header), so a duplicate between this
1033
+ // screen's icon and one a DIFFERENT screen's pull wrote is exactly as real as one within this pull, and
1034
+ // a per-pull-array computation could never see that. The clobber-avoidance in
1035
+ // writeAssets means a NEW duplicate of this shape mostly can't be created (a re-pull of an
1036
+ // unchanged icon reuses the existing file), but files already on disk from an earlier export — or two
1037
+ // independently-named layers that just happen to render the same icon — still need catching, hence the
1038
+ // directory's ContentIndex (the same one writeAssets reused with) rather than trusting the write history.
1039
+ // Files are never deleted or merged: a re-pull points at the representative, the rest stay listed.
1040
+
1041
+ export type { AssetOwner, AssetContext } from "./asset-owners.ts";
1042
+ /** One row of <Screen>.assets.json `files[]` / `reference[]`. The palette fields are present on SVGs only;
1043
+ * the owner fields on rows the screen's tree points at. */
1044
+ export interface AssetIndexEntry {
1045
+ file: string;
1046
+ node: string | undefined;
1047
+ bytes: number;
1048
+ hash: string | null;
1049
+ from?: string[];
1050
+ colors?: string[];
1051
+ monochrome?: boolean;
1052
+ paths?: number;
1053
+ /** Asset.name — the layer name of `node` */
1054
+ name?: string;
1055
+ /** the owner of `node` (else of the first use that has one) */
1056
+ owner?: AssetOwner;
1057
+ /** the instance above the owner */
1058
+ context?: AssetContext;
1059
+ /** every node id in THIS screen whose `asset` is this file, tree order, capped at USED_BY_CAP */
1060
+ usedBy?: string[];
1061
+ /** the full count, present only when `usedBy` was capped */
1062
+ usedByCount?: number;
1063
+ /** how many of those uses are hidden (the node or an ancestor); absent when none */
1064
+ hiddenUses?: number;
1065
+ /** the hidden-reuse key of the use `owner` describes (asset-owners.ts reuseKeyOf) — what another screen's
1066
+ * hidden graphic matches on; absent when that use is hidden */
1067
+ reuseKey?: string;
1068
+ /** how many `<image>` elements the SVG embeds; absent when none */
1069
+ embeddedRaster?: number;
1070
+ /** a file this pull did not export: a hidden node here reuses it from a visible twin in ANOTHER screen
1071
+ * — the twin's node id (`assetFrom` of the row's `node`); absent on every file this pull wrote */
1072
+ reusedFrom?: string;
1073
+ }
1074
+ const USED_BY_CAP = 50;
1075
+
1076
+ // The owner half of a row: `uses` are the tree's nodes pointing at the row's file, in tree order.
1077
+ function ownerFields(entry: AssetIndexEntry, uses: readonly Graphic[] | undefined): void {
1078
+ if (!uses || !uses.length) return;
1079
+ const primary = uses.find((g) => g.node.id === entry.node && g.owner) || uses.find((g) => g.owner);
1080
+ if (primary && primary.owner) {
1081
+ entry.owner = primary.owner;
1082
+ if (primary.context) entry.context = primary.context;
1083
+ if (primary.reuseKey !== undefined && !primary.hidden) entry.reuseKey = primary.reuseKey;
1084
+ }
1085
+ entry.usedBy = uses.slice(0, USED_BY_CAP).map((g) => g.node.id);
1086
+ if (uses.length > USED_BY_CAP) entry.usedByCount = uses.length;
1087
+ const hidden = uses.filter((g) => g.hidden).length;
1088
+ if (hidden) entry.hiddenUses = hidden;
1089
+ }
1090
+
1091
+ // `tree` is the screen's nodes as written (after rewiring and hidden reuse) — the owner fields come from it;
1092
+ // `index` the pull's PullIndex writeAssets already used (one is made here when absent).
1093
+ function writeScreenAssets(dir: string, paths: ScreenPaths, assets: Asset[] | null | undefined, tree?: readonly IrNode[] | null, index?: PullIndex): { count: number; duplicates: number; totalBytes: number } | null {
1094
+ const uses = usesByPointer(collectGraphics(tree));
1095
+ // No index for a pull that exported nothing — unless a hidden node reuses another screen's file.
1096
+ const reusesAny = [...uses.values()].some((list) => list.some((u) => typeof u.node.assetFrom === "string"));
1097
+ if (!Array.isArray(assets) || (!assets.length && !reusesAny)) return null;
1098
+ // `duplicates` reports only the groups holding a file this screen lists — its shippable assets and the files its
1099
+ // hidden nodes reuse (never the reference PNG: below) — so only those files' buckets are clustered, and a pull
1100
+ // listing none (a reference PNG alone) does not build the index at all.
1101
+ const want = new Set<string>();
1102
+ for (const a of assets) if (a && a.file && a.kind !== "reference") want.add(path.basename(a.file));
1103
+ for (const [pointer, list] of uses) if (list.some((u) => typeof u.node.assetFrom === "string")) want.add(path.basename(pointer));
1104
+ const groups = want.size ? (index || new PullIndex(path.join(dir, "assets"))).get().groupsOf(want).filter((g) => g.files.length > 1) : [];
1105
+ const groupOf = new Map<string, { hash: string; files: string[] }>(); // basename -> its duplicate group
1106
+ for (const g of groups) for (const f of g.files) groupOf.set(f, g);
1107
+ const files: AssetIndexEntry[] = [];
1108
+ const reference: AssetIndexEntry[] = []; // the frame's own screenshot — see note below on why it's split out
1109
+ const dupHashes = new Set<string>();
1110
+ for (const a of assets) {
1111
+ if (!a || !a.file) continue;
1112
+ const bytes = a.text != null ? Buffer.from(a.text, "utf8") : a.base64 != null ? Buffer.from(a.base64, "base64") : null;
1113
+ const hash = bytes ? sha1Hex(bytes) : null; // exact bytes-on-disk hash (writeAssets keeps a.text/a.base64 in sync with disk on reuse)
1114
+ const file = "assets/" + path.basename(a.file);
1115
+ const entry: AssetIndexEntry = { file, node: a.id, bytes: bytes ? bytes.length : 0, hash };
1116
+ if (typeof a.name === "string" && a.name) entry.name = a.name;
1117
+ if (Array.isArray(a.from) && a.from.length > 1) entry.from = a.from; // one file, several nodes reached it
1118
+ if (a.text != null && /\.svg$/i.test(a.file)) Object.assign(entry, svgPalette(a.text));
1119
+ if (a.kind !== "reference") ownerFields(entry, uses.get(file));
1120
+ // The whole-frame reference PNG (a.kind === "reference", <id>_ref.png — see figma-plugin/src/assets.ts)
1121
+ // is a discovery/self-check aid, not a shippable UI asset: on one real screen it was 196,049 of
1122
+ // 312,234 total bytes (63%) and headed the `heavy` list purely because of its own size.
1123
+ // It still gets a manifest ROW (build-screen looks it up), just not counted into `count`/`totalBytes`,
1124
+ // which exist to answer "how much of this do I actually ship".
1125
+ if (a.kind === "reference") { reference.push(entry); continue; }
1126
+ files.push(entry);
1127
+ const g = groupOf.get(path.basename(a.file));
1128
+ if (g) dupHashes.add(g.hash);
1129
+ }
1130
+ // A hidden node that reuses a file from ANOTHER screen's pull (reuseHiddenAssets' cross-screen step)
1131
+ // points at a file this pull never exported — still one this screen references, so it gets a row too,
1132
+ // its bytes read from disk, `reusedFrom` naming the twin. A pointer that names no file on disk (by exact
1133
+ // name) gets no row: danglingPointers already warned about it.
1134
+ const listed = new Set([...files, ...reference].map((f) => f.file));
1135
+ const onDisk = exactExists(dir);
1136
+ for (const [pointer, list] of uses) {
1137
+ const reused = list.find((u) => typeof u.node.assetFrom === "string");
1138
+ if (listed.has(pointer) || !reused || !onDisk(pointer)) continue;
1139
+ let bytes: Buffer;
1140
+ try { bytes = fs.readFileSync(path.join(dir, pointer)); } catch { continue; }
1141
+ const entry: AssetIndexEntry = { file: pointer, node: reused.node.id, bytes: bytes.length, hash: sha1Hex(bytes) };
1142
+ if (typeof reused.node.name === "string" && reused.node.name) entry.name = reused.node.name;
1143
+ if (/\.svg$/i.test(pointer)) Object.assign(entry, svgPalette(bytes.toString("utf8")));
1144
+ ownerFields(entry, list);
1145
+ if (typeof reused.node.assetFrom === "string") entry.reusedFrom = reused.node.assetFrom;
1146
+ files.push(entry);
1147
+ listed.add(pointer);
1148
+ const g = groupOf.get(path.basename(pointer));
1149
+ if (g) dupHashes.add(g.hash);
1150
+ }
1151
+ const duplicates = groups.filter((g) => dupHashes.has(g.hash)).map((g) => {
1152
+ const group = g.files.map((f) => "assets/" + f); // the representative first
1153
+ const first = files.find((f) => group.includes(f.file));
1154
+ return { hash: g.hash, bytes: first ? first.bytes : undefined, files: group };
1155
+ });
1156
+ const monochrome = files.filter((f) => f.monochrome).map((f) => f.file);
1157
+ // `f.paths` is undefined on a non-SVG row, and `undefined >= n` is false.
1158
+ const heavy = files.filter((f) => f.bytes >= BIG_ASSET_BYTES || (f.paths !== undefined && f.paths >= BUSY_SVG_PATHS)).map((f) => ({ file: f.file, bytes: f.bytes, paths: f.paths, ...ifDefined("embeddedRaster", f.embeddedRaster) }));
1159
+ const doc = {
1160
+ screen: paths.base,
1161
+ count: files.length,
1162
+ totalBytes: files.reduce((n, f) => n + f.bytes, 0),
1163
+ duplicates,
1164
+ monochrome,
1165
+ heavy,
1166
+ reference: reference.length ? reference : undefined,
1167
+ note:
1168
+ "Every SHIPPABLE asset this screen references, with a content hash. `duplicates` is computed over " +
1169
+ "the WHOLE shared assets/ directory (every screen ever pulled), not just this screen's own files: two " +
1170
+ "SVGs are the same asset when they differ only in def ids and in numbers by at most 0.01 (Figma's own " +
1171
+ "re-export noise, bridge/src/svg-normalize.ts svgFingerprint) — the numbers of a transform on <use>/<image> " +
1172
+ "and of a <pattern>'s x/y/width/height (an embedded image's scale, offset and tile) relatively, by at most " +
1173
+ "1 % of their size; other files must be byte-identical. Each " +
1174
+ "group lists its representative first — the file a re-pull points at; the others are never deleted. " +
1175
+ "`owner`/`context`/`name` say what each file IS (the component and variant that own it) — search those, " +
1176
+ "not file names; `usedBy` lists this screen's nodes that point at it; `reusedFrom` marks a file another " +
1177
+ "screen's pull exported that a hidden node here reuses. `monochrome` lists the SVGs whose every fill/stroke is one colour — those " +
1178
+ "are the ones safe to recolour to currentColor at render time; the rest carry semantic colour (a red " +
1179
+ "trash, a green tick) and must keep it. `heavy` lists assets too large to inline. `reference` (if " +
1180
+ "present) is the frame's own whole-screen screenshot — useful for visual comparison, not something " +
1181
+ "the app ships, so it is excluded from `count`/`totalBytes`. Never edit an exported asset in place: " +
1182
+ "the producer owns these filenames and a re-pull will overwrite them.",
1183
+ files,
1184
+ };
1185
+ writeJson(dir, paths.assets, doc, true);
1186
+ return { count: files.length, duplicates: duplicates.length, totalBytes: doc.totalBytes };
1187
+ }
1188
+
1189
+ // variables.json is the ONE file two single-screen pulls share, and it must not be replaced wholesale
1190
+ // — pulling screen B would delete screen A's tokens. It accumulates:
1191
+ // the raw per-pull slice is kept verbatim under variables/<Screen>.json, and variables.json is the
1192
+ // union of every slice, keyed on each variable's Figma key. See variables-merge.ts for the rules.
1193
+ function writeScreenVariables(dir: string, paths: ScreenPaths, slice: VariablesDoc, log?: Log): { total: number; fromThisScreen: number; added: number; conflicts: number; screens: number } {
1194
+ writeJson(dir, paths.variables, slice, true);
1195
+
1196
+ const target = path.join(dir, MERGED_VARIABLES_FILE);
1197
+ let prev: unknown = readJsonOr(target, null);
1198
+ const { doc, stats } = mergeVariablesDoc(prev, slice, { screen: paths.base, file: paths.screen });
1199
+ writeJson(dir, MERGED_VARIABLES_FILE, doc, true);
1200
+
1201
+ if (log) {
1202
+ const sliceN = Array.isArray(slice.variables) ? slice.variables.length : 0;
1203
+ if (stats.first) {
1204
+ log(`wrote ${target} — ${sliceN} variable(s) from this screen (later pulls MERGE into this file, they do not replace it)`);
1205
+ } else {
1206
+ log(
1207
+ `merged ${target} — ${sliceN} from this screen -> ${doc.variables.length} total across ${doc._slices.length} screen(s) ` +
1208
+ `(+${stats.added} new, ${stats.kept} already known)`
1209
+ );
1210
+ }
1211
+ if (stats.conflicts.length) {
1212
+ log(
1213
+ `warn ${stats.conflicts.length} variable(s) resolve DIFFERENTLY in this screen than in an earlier pull — newest kept, all listed under _conflicts: ` +
1214
+ stats.conflicts.slice(0, 3).map((c) => `'${c.name}'`).join(", ") +
1215
+ (stats.conflicts.length > 3 ? ", …" : "")
1216
+ );
1217
+ }
1218
+ log(`wrote ${path.join(dir, paths.variables)} — this screen's slice on its own`);
1219
+ // A token/theme file generated from variables.json before this pull does not cover it. Bridge
1220
+ // text only (no design-to-code import); the tokens script's --check is what compares the two.
1221
+ if (!stats.first && stats.added > 0) {
1222
+ log(`info ${stats.added} new variable(s) merged into variables.json — a token/theme file generated from it is now behind (the tokens script's --check says)`);
1223
+ }
1224
+ }
1225
+ return { total: doc.variables.length, fromThisScreen: Array.isArray(slice.variables) ? slice.variables.length : 0, added: stats.added, conflicts: stats.conflicts.length, screens: doc._slices.length };
1226
+ }
1227
+
1228
+ // Dispatch on the RESULT shape, so each export tool forwards whatever the plugin sent without
1229
+ // having to know which writer its own command implies. Each member of ExportReply carries a field
1230
+ // the others do not — `screen` (a screen pull), `reference` (a screenshot), otherwise a catalog with
1231
+ // or without a page walk — so `in` narrows the union to the writer's own reply type.
1232
+ // Overloaded so a caller that knows its reply's kind gets that writer's own result back (the CLI prints a
1233
+ // screenshot's path and pixel size from it); a caller holding the union gets the union.
1234
+ type WriteAnyOpts = { scale?: number | undefined; keepPrev?: boolean | undefined };
1235
+ function writeAny(outDir: string | null | undefined, r: Stamped<ScreenshotReply>, log?: Log, opts?: WriteAnyOpts): ReturnType<typeof writeScreenshot>;
1236
+ function writeAny(outDir: string | null | undefined, r: Stamped<ExportReply>, log?: Log, opts?: WriteAnyOpts): ReturnType<typeof writeScreen> | ReturnType<typeof writeScreenshot> | ReturnType<typeof writeExport>;
1237
+ function writeAny(outDir: string | null | undefined, r: Stamped<ExportReply>, log?: Log, opts?: WriteAnyOpts) {
1238
+ if ("screen" in r) return writeScreen(outDir, r, log, opts && opts.keepPrev !== undefined ? { keepPrev: opts.keepPrev } : undefined);
1239
+ if ("reference" in r) return writeScreenshot(outDir, r, log, opts);
1240
+ return writeExport(outDir, r, log, opts && opts.keepPrev !== undefined ? { keepPrev: opts.keepPrev } : undefined);
1241
+ }
1242
+
1243
+ // How many characters an INLINE tool result may be before the MCP client truncates it. Claude Code caps
1244
+ // tool output at MAX_MCP_OUTPUT_TOKENS (default 25,000); ~4 characters per token, and 20% headroom
1245
+ // because that ratio is an estimate and indented JSON tokenizes worse than prose. Capped at 48,000:
1246
+ // Claude Code also saves any text result longer than 50,000 characters to a file instead of showing it,
1247
+ // whatever MAX_MCP_OUTPUT_TOKENS says — so past that the inline result is not inline anyway, and our own
1248
+ // spill (files + a compact index) is the better answer. MAX_MCP_OUTPUT_TOKENS can still LOWER the limit.
1249
+ const INLINE_CHAR_CAP = 48000;
1250
+ function inlineLimitChars(env: NodeJS.ProcessEnv = process.env): number {
1251
+ const tokens = Number(env.MAX_MCP_OUTPUT_TOKENS);
1252
+ return Math.min(Math.floor((tokens > 0 ? tokens : 25000) * 4 * 0.8), INLINE_CHAR_CAP);
1253
+ }
1254
+
1255
+ export { inlineLimitChars, resolveOutDir, assertInsideCwd, writeJson, writePages, writeDesignSystem, writeLibrary, writeAssets, writeScreenAssets, assetsGeometryWarning, assetsHiddenLine, writeScreenshot, writeScreenVariables, writeExport, writeScreen, writeAny, screenReuseKeys, resetScreenReuseKeys };