@design-twin/dtwin 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +959 -2
- package/figma-plugin/code.js +4207 -0
- package/figma-plugin/manifest.json +28 -0
- package/figma-plugin/ui.html +686 -0
- package/layer/cross-check.js +2746 -0
- package/layer/drift-lint.js +1526 -0
- package/layer/get-component.js +553 -0
- package/layer/map-bootstrap.js +1404 -0
- package/layer/map-validate.js +397 -0
- package/package.json +53 -4
- package/src/abort-reason.ts +12 -0
- package/src/asset-compare.ts +175 -0
- package/src/asset-owners.ts +280 -0
- package/src/atomic-write.ts +79 -0
- package/src/bridge-holder.ts +294 -0
- package/src/cli-commands.ts +427 -0
- package/src/client-presence.ts +146 -0
- package/src/clock.ts +45 -0
- package/src/commands.ts +561 -0
- package/src/daemon-auth.ts +174 -0
- package/src/daemon.ts +788 -0
- package/src/delivery.ts +85 -0
- package/src/design-system-layout.ts +232 -0
- package/src/doc-types.ts +1054 -0
- package/src/doctor.ts +602 -0
- package/src/errmsg.ts +14 -0
- package/src/export-reader.ts +409 -0
- package/src/export-verdict.ts +258 -0
- package/src/figma-mcp.ts +942 -0
- package/src/figma-pull.ts +1265 -0
- package/src/file-lock.ts +268 -0
- package/src/file-memo.ts +62 -0
- package/src/hash.ts +6 -0
- package/src/hex-color.ts +39 -0
- package/src/human-ms.ts +7 -0
- package/src/init.ts +424 -0
- package/src/instance-overrides.ts +7 -0
- package/src/json-file.ts +79 -0
- package/src/json-size.ts +108 -0
- package/src/json-util.ts +51 -0
- package/src/library-layout.ts +193 -0
- package/src/node-id.ts +69 -0
- package/src/pages-layout.ts +483 -0
- package/src/pending-requests.ts +187 -0
- package/src/plugin-frames.ts +32 -0
- package/src/png.ts +196 -0
- package/src/ports.ts +25 -0
- package/src/project-layout.ts +189 -0
- package/src/quick-keys.ts +68 -0
- package/src/read-json.ts +67 -0
- package/src/read-opts.ts +125 -0
- package/src/read-plan.ts +105 -0
- package/src/read-run.ts +119 -0
- package/src/route.ts +243 -0
- package/src/seed-components.ts +229 -0
- package/src/served-session.ts +347 -0
- package/src/server-core.ts +1093 -0
- package/src/settings.ts +156 -0
- package/src/source-stamp.ts +16 -0
- package/src/staleness.ts +63 -0
- package/src/stdio.ts +156 -0
- package/src/svg-normalize.ts +205 -0
- package/src/timeouts.ts +76 -0
- package/src/timings.ts +118 -0
- package/src/token-store.ts +261 -0
- package/src/variables-merge.ts +337 -0
- package/src/verbs.ts +140 -0
- package/src/write-out.ts +1255 -0
package/src/write-out.ts
ADDED
|
@@ -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 };
|