@design-twin/dtwin 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +959 -2
  3. package/figma-plugin/code.js +4207 -0
  4. package/figma-plugin/manifest.json +28 -0
  5. package/figma-plugin/ui.html +686 -0
  6. package/layer/cross-check.js +2746 -0
  7. package/layer/drift-lint.js +1526 -0
  8. package/layer/get-component.js +553 -0
  9. package/layer/map-bootstrap.js +1404 -0
  10. package/layer/map-validate.js +397 -0
  11. package/package.json +53 -4
  12. package/src/abort-reason.ts +12 -0
  13. package/src/asset-compare.ts +175 -0
  14. package/src/asset-owners.ts +280 -0
  15. package/src/atomic-write.ts +79 -0
  16. package/src/bridge-holder.ts +294 -0
  17. package/src/cli-commands.ts +427 -0
  18. package/src/client-presence.ts +146 -0
  19. package/src/clock.ts +45 -0
  20. package/src/commands.ts +561 -0
  21. package/src/daemon-auth.ts +174 -0
  22. package/src/daemon.ts +788 -0
  23. package/src/delivery.ts +85 -0
  24. package/src/design-system-layout.ts +232 -0
  25. package/src/doc-types.ts +1054 -0
  26. package/src/doctor.ts +602 -0
  27. package/src/errmsg.ts +14 -0
  28. package/src/export-reader.ts +409 -0
  29. package/src/export-verdict.ts +258 -0
  30. package/src/figma-mcp.ts +942 -0
  31. package/src/figma-pull.ts +1265 -0
  32. package/src/file-lock.ts +268 -0
  33. package/src/file-memo.ts +62 -0
  34. package/src/hash.ts +6 -0
  35. package/src/hex-color.ts +39 -0
  36. package/src/human-ms.ts +7 -0
  37. package/src/init.ts +424 -0
  38. package/src/instance-overrides.ts +7 -0
  39. package/src/json-file.ts +79 -0
  40. package/src/json-size.ts +108 -0
  41. package/src/json-util.ts +51 -0
  42. package/src/library-layout.ts +193 -0
  43. package/src/node-id.ts +69 -0
  44. package/src/pages-layout.ts +483 -0
  45. package/src/pending-requests.ts +187 -0
  46. package/src/plugin-frames.ts +32 -0
  47. package/src/png.ts +196 -0
  48. package/src/ports.ts +25 -0
  49. package/src/project-layout.ts +189 -0
  50. package/src/quick-keys.ts +68 -0
  51. package/src/read-json.ts +67 -0
  52. package/src/read-opts.ts +125 -0
  53. package/src/read-plan.ts +105 -0
  54. package/src/read-run.ts +119 -0
  55. package/src/route.ts +243 -0
  56. package/src/seed-components.ts +229 -0
  57. package/src/served-session.ts +347 -0
  58. package/src/server-core.ts +1093 -0
  59. package/src/settings.ts +156 -0
  60. package/src/source-stamp.ts +16 -0
  61. package/src/staleness.ts +63 -0
  62. package/src/stdio.ts +156 -0
  63. package/src/svg-normalize.ts +205 -0
  64. package/src/timeouts.ts +76 -0
  65. package/src/timings.ts +118 -0
  66. package/src/token-store.ts +261 -0
  67. package/src/variables-merge.ts +337 -0
  68. package/src/verbs.ts +140 -0
  69. package/src/write-out.ts +1255 -0
@@ -0,0 +1,397 @@
1
+ // GENERATED from design-to-code/ by claude-plugin/build-scripts.js — edit the source, then rebuild.
2
+
3
+
4
+ // design-to-code/cli-args.ts
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ // bridge/src/errmsg.ts
9
+ var errMsg = (e) => typeof e === "string" ? e : String(e && e.message || e);
10
+
11
+ // bridge/src/stdio.ts
12
+ import { format, inspect } from "node:util";
13
+ function installQueuedConsole(o = {}) {
14
+ trackWrites(process.stdout);
15
+ trackWrites(process.stderr);
16
+ const out = o.logTo === "stderr" ? process.stderr : process.stdout;
17
+ const err = process.stderr;
18
+ let indent = "";
19
+ const write = (s, text) => {
20
+ s.write((indent ? text.replace(/^/gm, indent) : text) + "\n");
21
+ };
22
+ const line = (s) => (...a) => {
23
+ write(s, format(...a));
24
+ };
25
+ console.log = console.info = console.debug = line(out);
26
+ console.warn = console.error = line(err);
27
+ console.dir = (item, options) => {
28
+ write(out, inspect(item, { customInspect: false, ...options }));
29
+ };
30
+ console.dirxml = line(out);
31
+ console.table = (data) => {
32
+ write(out, inspect(data));
33
+ };
34
+ console.write = (...data) => {
35
+ let n = 0;
36
+ for (const d of data) {
37
+ const b = typeof d === "string" ? Buffer.from(d) : d instanceof ArrayBuffer ? Buffer.from(d) : Buffer.from(d.buffer, d.byteOffset, d.byteLength);
38
+ if (b.length) {
39
+ out.write(b);
40
+ n += b.length;
41
+ }
42
+ }
43
+ return n;
44
+ };
45
+ console.clear = () => {
46
+ if (out.isTTY) out.write("\x1B[1;1H\x1B[0J");
47
+ };
48
+ console.trace = (...a) => {
49
+ const e = new Error(format(...a));
50
+ e.name = "Trace";
51
+ Error.captureStackTrace(e, console.trace);
52
+ write(err, e.stack ?? `Trace: ${e.message}`);
53
+ };
54
+ console.assert = (cond, ...a) => {
55
+ if (!cond) write(err, a.length ? "Assertion failed: " + format(...a) : "Assertion failed");
56
+ };
57
+ console.group = console.groupCollapsed = (...a) => {
58
+ if (a.length) write(out, format(...a));
59
+ indent += " ";
60
+ };
61
+ console.groupEnd = () => {
62
+ indent = indent.slice(0, -2);
63
+ };
64
+ const counts = /* @__PURE__ */ new Map();
65
+ console.count = (label = "default") => {
66
+ const n = (counts.get(label) ?? 0) + 1;
67
+ counts.set(label, n);
68
+ write(out, `${label}: ${n}`);
69
+ };
70
+ console.countReset = (label = "default") => {
71
+ counts.delete(label);
72
+ };
73
+ const timers = /* @__PURE__ */ new Map();
74
+ const elapsed = (label) => {
75
+ const t = timers.get(label);
76
+ return t === void 0 ? null : `${label}: ${(performance.now() - t).toFixed(3)}ms`;
77
+ };
78
+ console.time = (label = "default") => {
79
+ timers.set(label, performance.now());
80
+ };
81
+ console.timeLog = (label = "default", ...a) => {
82
+ const e = elapsed(label);
83
+ if (e === null) write(err, `Warning: No such label '${label}' for console.timeLog()`);
84
+ else write(out, [e, ...a.map((x) => format(x))].join(" "));
85
+ };
86
+ console.timeEnd = (label = "default") => {
87
+ const e = elapsed(label);
88
+ timers.delete(label);
89
+ write(e === null ? err : out, e ?? `Warning: No such label '${label}' for console.timeEnd()`);
90
+ };
91
+ }
92
+ var inFlight = /* @__PURE__ */ new Map();
93
+ function trackWrites(s) {
94
+ if (inFlight.has(s)) return;
95
+ const st = { n: 0, idle: [] };
96
+ inFlight.set(s, st);
97
+ const write = s.write.bind(s);
98
+ const settle = () => {
99
+ if (--st.n === 0) for (const r of st.idle.splice(0)) r();
100
+ };
101
+ s.write = (chunk, encoding, cb) => {
102
+ const done = typeof encoding === "function" ? encoding : cb;
103
+ const enc = typeof encoding === "function" ? void 0 : encoding;
104
+ if (chunk.length === 0) return write(chunk, enc, done);
105
+ st.n++;
106
+ let called = false;
107
+ return write(chunk, enc, (err) => {
108
+ if (!called) {
109
+ called = true;
110
+ settle();
111
+ }
112
+ done?.(err);
113
+ });
114
+ };
115
+ }
116
+ var CliExit = class extends Error {
117
+ code;
118
+ constructor(code) {
119
+ super(`exit ${code}`);
120
+ this.name = "CliExit";
121
+ this.code = code;
122
+ }
123
+ };
124
+ var cliDepth = 0;
125
+ function exitCli(code) {
126
+ if (cliDepth > 0) throw new CliExit(code);
127
+ process.exit(code);
128
+ }
129
+ function runCli(main2, o, argv = process.argv.slice(2)) {
130
+ installQueuedConsole();
131
+ const done = o.done ?? ((code) => {
132
+ process.exitCode = code;
133
+ });
134
+ const fail = (e) => {
135
+ if (e instanceof CliExit) return done(e.code);
136
+ console.error(o.errorLine ? o.errorLine(e) : `${o.name}: ${e instanceof Error && e.stack || String(e)}`);
137
+ done(o.errorCode ?? 1);
138
+ };
139
+ cliDepth++;
140
+ let settled = false;
141
+ const leave = () => {
142
+ if (!settled) {
143
+ settled = true;
144
+ cliDepth--;
145
+ }
146
+ };
147
+ try {
148
+ const r = main2(argv);
149
+ if (typeof r === "number") {
150
+ leave();
151
+ done(r);
152
+ } else r.then((c) => {
153
+ leave();
154
+ done(c);
155
+ }, (e) => {
156
+ leave();
157
+ fail(e);
158
+ });
159
+ } catch (e) {
160
+ leave();
161
+ fail(e);
162
+ }
163
+ }
164
+
165
+ // design-to-code/proc.ts
166
+ var pause = new Int32Array(new SharedArrayBuffer(4));
167
+
168
+ // design-to-code/cli-args.ts
169
+ var SELF = fileURLToPath(import.meta.url);
170
+ var shellQuote = (p) => /["$`\\!]/.test(p) ? `'${p.replaceAll("'", `'\\''`)}'` : `"${p}"`;
171
+ var SCRIPT_RUNNER = "bun --no-install --no-env-file";
172
+ var scriptCmd = (name) => `${SCRIPT_RUNNER} ${shellQuote(path.join(path.dirname(SELF), name + path.extname(SELF)))}`;
173
+
174
+ // bridge/src/read-json.ts
175
+ import fs from "node:fs";
176
+ var anyJson = (_x) => true;
177
+ function readFailure(e) {
178
+ const code = e && typeof e === "object" && "code" in e ? e.code : void 0;
179
+ if (code === "ENOENT") return { error: "does not exist", missing: true };
180
+ return {
181
+ error: code === "EISDIR" ? "is a directory, not a file" : code === "EACCES" ? "is not readable (permission denied)" : `could not be read (${String(code || e)})`
182
+ };
183
+ }
184
+ function readJson(file, guard) {
185
+ let buf;
186
+ try {
187
+ buf = fs.readFileSync(file);
188
+ } catch (e) {
189
+ return readFailure(e);
190
+ }
191
+ if (buf.length >= 2 && (buf[0] === 255 && buf[1] === 254 || buf[0] === 254 && buf[1] === 255)) {
192
+ return { error: "is UTF-16, not UTF-8 \u2014 re-save it as UTF-8" };
193
+ }
194
+ let raw = buf.toString("utf8");
195
+ if (raw.charCodeAt(0) === 65279) raw = raw.slice(1);
196
+ let parsed;
197
+ try {
198
+ const value = JSON.parse(raw);
199
+ parsed = value;
200
+ } catch (e) {
201
+ return { error: `is not valid JSON \u2014 ${errMsg(e)}` };
202
+ }
203
+ if (!guard(parsed)) return { error: `is not ${guard.expected || "the expected kind of document"}` };
204
+ return { doc: parsed };
205
+ }
206
+
207
+ // design-to-code/catalog-input.ts
208
+ function readJsonFile(file, what, hint) {
209
+ return readDocFile(file, what, anyJson, hint);
210
+ }
211
+ function readDocFile(file, what, guard, hint) {
212
+ return docOrExit(loadDocFile(file, what, guard, hint));
213
+ }
214
+ function loadDocFile(file, what, guard, hint, readAt = file) {
215
+ const r = readJson(readAt, guard);
216
+ if ("doc" in r) return r;
217
+ const ioFailure = r.missing || /^(is a directory|is not readable|could not be read)/.test(r.error);
218
+ return { error: `${what}: '${file}' ${r.error}${ioFailure ? "." : ""}` + (ioFailure && hint ? `
219
+ ${hint}` : "") };
220
+ }
221
+ function docOrExit(r) {
222
+ if ("doc" in r) return r.doc;
223
+ console.error(`error ${r.error}`);
224
+ exitCli(2);
225
+ }
226
+
227
+ // bridge/src/json-util.ts
228
+ function nullProto() {
229
+ return /* @__PURE__ */ Object.create(null);
230
+ }
231
+ function isRecord(x) {
232
+ return typeof x === "object" && x !== null && !Array.isArray(x);
233
+ }
234
+
235
+ // design-to-code/map-validate-core.ts
236
+ var STATUSES = ["active", "deprecated", "needs-review"];
237
+ var KEYS = {
238
+ root: ["version", "figmaFileKey", "components"],
239
+ entry: ["figma", "code", "props", "variantOverrides", "childrenByLayer", "status", "note"],
240
+ figma: ["key", "id", "name", "unstable"],
241
+ code: ["module", "export", "targets"],
242
+ target: ["module", "export"],
243
+ vo: ["when", "code"],
244
+ voCode: ["module", "export"],
245
+ children: ["layerNamePattern", "slot"],
246
+ prop: Object.assign(nullProto(), {
247
+ enum: ["kind", "codeProp", "values", "default", "omitDefault"],
248
+ boolean: ["kind", "codeProp", "default", "omitDefault"],
249
+ string: ["kind", "codeProp"],
250
+ instance: ["kind", "codeProp", "slot"]
251
+ })
252
+ };
253
+ var PROP_KINDS = Object.keys(KEYS.prop);
254
+ var isStr = (v) => typeof v === "string";
255
+ var isBool = (v) => typeof v === "boolean";
256
+ var noExtra = (obj, allowed, at, err) => {
257
+ if (!Array.isArray(allowed)) return;
258
+ for (const k of Object.keys(obj)) if (!allowed.includes(k)) err(`${at}.${k}`, "unknown property (additionalProperties:false)");
259
+ };
260
+ var optStrings = (obj, keys, at, err) => {
261
+ for (const k of keys) if (obj[k] !== void 0 && !isStr(obj[k])) err(`${at}.${k}`, "must be a string");
262
+ };
263
+ function validateMap(map) {
264
+ const errors = [];
265
+ const err = (path2, message) => errors.push({ path: path2, message });
266
+ if (!isRecord(map)) return { ok: false, errors: [{ path: "", message: "map must be an object" }] };
267
+ noExtra(map, KEYS.root, "", err);
268
+ if (map.version !== 1) err("version", "must be 1");
269
+ if (map.figmaFileKey !== void 0 && !isStr(map.figmaFileKey)) err("figmaFileKey", "must be a string");
270
+ if (!isRecord(map.components)) {
271
+ err("components", "must be an object");
272
+ return { ok: false, errors };
273
+ }
274
+ for (const key of Object.keys(map.components)) {
275
+ const e = map.components[key];
276
+ const at = `components.${key}`;
277
+ if (!isRecord(e)) {
278
+ err(at, "entry must be an object");
279
+ continue;
280
+ }
281
+ noExtra(e, KEYS.entry, at, err);
282
+ if (!isRecord(e.figma)) err(`${at}.figma`, "required object");
283
+ else {
284
+ noExtra(e.figma, KEYS.figma, `${at}.figma`, err);
285
+ if (!isStr(e.figma.name)) err(`${at}.figma.name`, "required string");
286
+ optStrings(e.figma, ["key", "id"], `${at}.figma`, err);
287
+ if (e.figma.unstable !== void 0 && !isBool(e.figma.unstable)) err(`${at}.figma.unstable`, "must be a boolean");
288
+ }
289
+ if (!isRecord(e.code)) err(`${at}.code`, "required object");
290
+ else {
291
+ noExtra(e.code, KEYS.code, `${at}.code`, err);
292
+ if (!isStr(e.code.module)) err(`${at}.code.module`, "required string");
293
+ if (!isStr(e.code.export)) err(`${at}.code.export`, "required string");
294
+ if (e.code.targets !== void 0) {
295
+ if (!isRecord(e.code.targets)) err(`${at}.code.targets`, "must be an object");
296
+ else for (const t of Object.keys(e.code.targets)) {
297
+ const tv = e.code.targets[t], ta = `${at}.code.targets.${t}`;
298
+ if (!isRecord(tv)) err(ta, "must be an object");
299
+ else {
300
+ noExtra(tv, KEYS.target, ta, err);
301
+ optStrings(tv, ["module", "export"], ta, err);
302
+ }
303
+ }
304
+ }
305
+ }
306
+ if (e.status !== void 0 && (!isStr(e.status) || !STATUSES.includes(e.status))) err(`${at}.status`, `must be one of ${STATUSES.join("|")}`);
307
+ optStrings(e, ["note"], at, err);
308
+ if (e.props !== void 0) {
309
+ if (!isRecord(e.props)) err(`${at}.props`, "must be an object");
310
+ else for (const pn of Object.keys(e.props)) validateProp(e.props[pn], `${at}.props.${pn}`, err);
311
+ }
312
+ if (e.variantOverrides !== void 0) {
313
+ if (!Array.isArray(e.variantOverrides)) err(`${at}.variantOverrides`, "must be an array");
314
+ else e.variantOverrides.forEach((vo, i) => {
315
+ const va = `${at}.variantOverrides[${i}]`;
316
+ if (!isRecord(vo)) {
317
+ err(va, "must be an object");
318
+ return;
319
+ }
320
+ noExtra(vo, KEYS.vo, va, err);
321
+ if (!isRecord(vo.when)) err(`${va}.when`, "required object");
322
+ else for (const wk of Object.keys(vo.when)) if (!isStr(vo.when[wk])) err(`${va}.when.${wk}`, "value must be a string");
323
+ if (!isRecord(vo.code)) err(`${va}.code`, "required object");
324
+ else {
325
+ noExtra(vo.code, KEYS.voCode, `${va}.code`, err);
326
+ if (!isStr(vo.code.module)) err(`${va}.code.module`, "required string");
327
+ if (!isStr(vo.code.export)) err(`${va}.code.export`, "required string");
328
+ }
329
+ });
330
+ }
331
+ if (e.childrenByLayer !== void 0) {
332
+ if (!isRecord(e.childrenByLayer)) err(`${at}.childrenByLayer`, "must be an object");
333
+ else {
334
+ noExtra(e.childrenByLayer, KEYS.children, `${at}.childrenByLayer`, err);
335
+ optStrings(e.childrenByLayer, ["layerNamePattern", "slot"], `${at}.childrenByLayer`, err);
336
+ }
337
+ }
338
+ }
339
+ return { ok: errors.length === 0, errors };
340
+ }
341
+ function validateProp(p, at, err) {
342
+ if (!isRecord(p)) {
343
+ err(at, "prop must be an object");
344
+ return;
345
+ }
346
+ const allowed = typeof p.kind === "string" ? KEYS.prop[p.kind] : void 0;
347
+ if (!Array.isArray(allowed)) {
348
+ err(`${at}.kind`, `must be one of ${PROP_KINDS.join("|")}`);
349
+ return;
350
+ }
351
+ noExtra(p, allowed, at, err);
352
+ if (p.kind !== "instance" && !isStr(p.codeProp)) err(`${at}.codeProp`, "required string");
353
+ if (p.kind === "instance" && p.codeProp !== void 0 && !isStr(p.codeProp)) err(`${at}.codeProp`, "must be a string");
354
+ if (p.kind === "instance" && p.slot !== void 0 && !isStr(p.slot)) err(`${at}.slot`, "must be a string");
355
+ if (p.kind === "enum") {
356
+ if (p.values !== void 0 && !isRecord(p.values)) err(`${at}.values`, "must be an object (VARIANT option -> code value)");
357
+ else if (isRecord(p.values)) for (const k of Object.keys(p.values)) {
358
+ const t = typeof p.values[k];
359
+ if (p.values[k] !== null && !["string", "number", "boolean"].includes(t)) err(`${at}.values.${k}`, "value must be string|number|boolean|null");
360
+ }
361
+ if (p.default !== void 0 && !["string", "number", "boolean"].includes(typeof p.default)) err(`${at}.default`, "must be string|number|boolean");
362
+ }
363
+ if (p.kind === "boolean" && p.default !== void 0 && !isBool(p.default)) err(`${at}.default`, "must be a boolean");
364
+ if ((p.kind === "enum" || p.kind === "boolean") && p.omitDefault !== void 0 && !isBool(p.omitDefault)) err(`${at}.omitDefault`, "must be a boolean");
365
+ }
366
+ function isCodeConnectMap(x) {
367
+ return validateMap(x).ok;
368
+ }
369
+ isCodeConnectMap.expected = "a valid component map (the map-validate script lists what is wrong with it)";
370
+
371
+ // design-to-code/map-validate.ts
372
+ function main(argv) {
373
+ const file = argv[0];
374
+ const USAGE = `usage: ${scriptCmd("map-validate")} <map.json>`;
375
+ if (file === "--help" || file === "-h") {
376
+ console.log(USAGE);
377
+ return 0;
378
+ }
379
+ if (!file || file.startsWith("-")) {
380
+ console.error((file ? `map-validate: unknown flag ${file}
381
+ ` : "") + USAGE);
382
+ return 1;
383
+ }
384
+ const res = validateMap(readJsonFile(file, "component map"));
385
+ if (res.ok) {
386
+ console.log("map valid");
387
+ return 0;
388
+ }
389
+ res.errors.forEach((e) => console.error(` ${e.path || "(root)"}: ${e.message}`));
390
+ console.error(`
391
+ ${res.errors.length} error(s)`);
392
+ return 1;
393
+ }
394
+ if (import.meta.main) runCli(main, { name: "map-validate" });
395
+ export {
396
+ main
397
+ };
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "@design-twin/dtwin",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Design Twin bridge: local CLI + MCP server between Claude Code and the Design Twin Figma plugin. Reads a design on the free Figma plan, fully offline.",
5
+ "license": "MIT",
6
+ "author": "Mohamed Magdy Omar",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/its-magdy/design-twin.git",
10
+ "directory": "bridge"
11
+ },
12
+ "homepage": "https://github.com/its-magdy/design-twin/tree/main/bridge#readme",
13
+ "bugs": "https://github.com/its-magdy/design-twin/issues",
14
+ "keywords": [
15
+ "figma",
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "design-to-code",
19
+ "design-system",
20
+ "design-tokens",
21
+ "claude-code",
22
+ "figma-plugin",
23
+ "cli"
24
+ ],
25
+ "type": "module",
26
+ "engines": {
27
+ "bun": ">=1.4.2"
28
+ },
29
+ "bin": {
30
+ "dtwin": "src/figma-pull.ts"
31
+ },
32
+ "files": [
33
+ "src",
34
+ "layer",
35
+ "figma-plugin",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "scripts": {
40
+ "typecheck": "tsc --noEmit",
41
+ "prepack": "bun --no-install --no-env-file pack.ts",
42
+ "postpack": "bun --no-install --no-env-file pack.ts --clean",
43
+ "pull": "bun src/figma-pull.ts",
44
+ "pull:selection": "bun src/figma-pull.ts design --selection",
45
+ "mcp": "bun src/figma-mcp.ts"
46
+ },
47
+ "dependencies": {
48
+ "@modelcontextprotocol/sdk": "^1.32.1",
49
+ "zod": "^4.6.7"
50
+ },
51
+ "devDependencies": {
52
+ "@types/bun": "^1.4.2",
53
+ "typescript": "^7.0.2"
54
+ }
55
+ }
@@ -0,0 +1,12 @@
1
+ // abort-reason.ts — bridge-side only (AbortSignal, DOMException): json-util.ts is also type-checked by the
2
+ // plugin, which has no DOM lib.
3
+
4
+ /**
5
+ * Why a request the caller gave up on is rejected: the signal's reason when that is an Error of the
6
+ * caller's own, else `fallback`. A bare abort() carries a DOMException "AbortError" (itself an Error in
7
+ * Node), which names nothing, so it gets the fixed text. The one rule for the bridge and the daemon client.
8
+ */
9
+ export function abortReason(signal: AbortSignal, fallback: string): Error {
10
+ const r: unknown = signal.reason;
11
+ return r instanceof Error && !(r instanceof DOMException) ? r : new Error(fallback);
12
+ }
@@ -0,0 +1,175 @@
1
+ // asset-compare.ts — bridge-side byte comparison built on svg-normalize.ts's two definitions: the v1
2
+ // normalised hash (normalizeForCompare/sameAsset — design-diff's, unchanged) and the fingerprint index
3
+ // the shared assets/ directory dedups with (ContentIndex, below).
4
+ //
5
+ // Split out of svg-normalize.ts (rather than living there) because this needs `crypto`, which the
6
+ // Figma plugin sandbox does not have — and svg-normalize.ts must type-check under the plugin's
7
+ // tsconfig (no Node types) and bundle into the plugin, so an `import … from "node:crypto"` anywhere
8
+ // in a file the plugin bundle imports breaks that build, even for a function the bundle never calls.
9
+ // svg-normalize.ts stays crypto-free so figma-plugin/src/util.ts can safely re-export it; this file
10
+ // is imported only from the Bun side (bridge/src/write-out.ts, design-to-code/design-diff.ts — the latter
11
+ // bundled by claude-plugin/build-scripts.ts the same way it already bundles bridge/src/export-verdict.ts).
12
+ import crypto from "node:crypto";
13
+ import fs from "node:fs";
14
+ import { normalizeSvgText, isSvgName, svgFingerprint, numsWithin, relWithin, SVG_TOL } from "./svg-normalize.ts";
15
+ import { FileMemo } from "./file-memo.ts";
16
+
17
+ // `content` may be a Buffer (bytes on disk / about to be written) or a string (an SVG the plugin just
18
+ // produced, before it's ever touched a filesystem) — normalised either way. Always returns a Buffer.
19
+ export function normalizeForCompare(fileName: unknown, content: Buffer | string): Buffer {
20
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content), "utf8");
21
+ if (!isSvgName(fileName)) return buf;
22
+ return Buffer.from(normalizeSvgText(buf.toString("utf8")), "utf8");
23
+ }
24
+
25
+ export function sha1Hex(buf: Buffer | string): string {
26
+ return crypto.createHash("sha1").update(buf).digest("hex");
27
+ }
28
+
29
+ // The comparison every "is this the same asset" decision should use: true if the two byte strings are
30
+ // the same asset MODULO Figma's own export noise (SVG coordinate rounding only; PNG/other bytes are
31
+ // compared exactly — a changed pixel there is real signal, not noise).
32
+ export function sameAsset(fileName: unknown, a: Buffer | string, b: Buffer | string): boolean {
33
+ return Buffer.compare(normalizeForCompare(fileName, a), normalizeForCompare(fileName, b)) === 0;
34
+ }
35
+
36
+ // ---- ContentIndex: "is this content already in assets/, under ANY name"
37
+ //
38
+ // A hash cannot express "within a tolerance", so the index is two-level: the KEY is exact — sha1 of the
39
+ // SVG's fingerprint skeleton (ids canonical, numbers out), or sha1 of the bytes for anything that is not an
40
+ // SVG (a changed pixel is real signal) — and inside one key's bucket the members are compared on their
41
+ // numbers, every one within SVG_TOL (a `<use>`/`<image>` transform's relatively — see svg-normalize.ts). Members are kept in
42
+ // REPRESENTATIVE order — the plain name before a `-<6 hex>[_N]` suffixed copy, then the shortest, then
43
+ // alphabetical (ownCopyWithContent's order in write-out.ts) — so `find` names the same file whichever order
44
+ // the directory listed them in.
45
+
46
+ /** The exact half of an asset's content identity, plus the numbers its tolerance applies to (`rel`: the
47
+ * relatively compared ones). */
48
+ export interface ContentKey { key: string; nums: Float64Array; rel: Float64Array }
49
+ interface Member { name: string; nums: Float64Array; rel: Float64Array }
50
+ /** One cluster of files the index treats as the same asset; `files[0]` is its representative. */
51
+ export interface ContentGroup { hash: string; files: string[] }
52
+
53
+ const EMPTY_NUMS = new Float64Array(0);
54
+ const SUFFIXED_RE = /-[0-9a-z]{6}(_\d+)?\.[^.]+$/i;
55
+
56
+ /** Representative order: plain name first, then shortest, then alphabetical. */
57
+ export function representativeOrder(x: string, y: string): number {
58
+ const sx = SUFFIXED_RE.test(x) ? 1 : 0, sy = SUFFIXED_RE.test(y) ? 1 : 0;
59
+ return sx - sy || x.length - y.length || (x < y ? -1 : x > y ? 1 : 0);
60
+ }
61
+
62
+ export function contentKey(fileName: unknown, content: Buffer | string): ContentKey {
63
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content), "utf8");
64
+ if (!isSvgName(fileName)) return { key: "b:" + sha1Hex(buf), nums: EMPTY_NUMS, rel: EMPTY_NUMS };
65
+ const fp = svgFingerprint(buf.toString("utf8"));
66
+ return { key: "s:" + sha1Hex(fp.skeleton), nums: fp.nums, rel: fp.rel };
67
+ }
68
+
69
+ // A stable id for a cluster: its key plus its representative's numbers rounded to 2 places (`-0.00` folded
70
+ // into `0.00`), and its `rel` numbers to 3 significant digits (two clusters of one key may differ only
71
+ // there). Reported as `duplicates[].hash` — it names the group, it is not a hash of any one file.
72
+ function groupHash(key: string, m: Member): string {
73
+ let text = key + "|";
74
+ for (const n of m.nums) { const f = n.toFixed(2); text += (f === "-0.00" ? "0.00" : f) + ","; }
75
+ if (m.rel.length) { text += "|"; for (const n of m.rel) text += (n === 0 ? "0" : n.toPrecision(3)) + ","; }
76
+ return sha1Hex(text);
77
+ }
78
+ const within = (a: Member | ContentKey, b: Member | ContentKey, tol: number): boolean => numsWithin(a.nums, b.nums, tol) && relWithin(a.rel, b.rel, tol);
79
+
80
+ export class ContentIndex {
81
+ private readonly buckets = new Map<string, Member[]>();
82
+ // name -> the key of the bucket holding it, so a replace touches one bucket instead of scanning them all.
83
+ private readonly bucketOf = new Map<string, string>();
84
+ // Buckets added to since they were last sorted. Sorting is deferred to the next read of the bucket, so
85
+ // building the index over a directory sorts each bucket once instead of once per add. The order is the
86
+ // same either way: representativeOrder never ties two different names.
87
+ private readonly unsorted = new Set<string>();
88
+ private readonly tol: number;
89
+ constructor(tol: number = SVG_TOL) { this.tol = tol; }
90
+
91
+ private sortedBucket(key: string): Member[] | undefined {
92
+ const bucket = this.buckets.get(key);
93
+ if (bucket && this.unsorted.delete(key)) bucket.sort((x, y) => representativeOrder(x.name, y.name));
94
+ return bucket;
95
+ }
96
+
97
+ /** The first member (representative order) the same as `k`, or undefined. */
98
+ findKey(k: ContentKey): string | undefined {
99
+ const bucket = this.sortedBucket(k.key);
100
+ if (!bucket) return undefined;
101
+ for (const m of bucket) if (within(m, k, this.tol)) return m.name;
102
+ return undefined;
103
+ }
104
+ find(fileName: string, content: Buffer | string): string | undefined { return this.findKey(contentKey(fileName, content)); }
105
+
106
+ /** Add a file under `name` (a name already present is replaced, so a rewrite never leaves a stale member). */
107
+ addKey(name: string, k: ContentKey): void {
108
+ const prevKey = this.bucketOf.get(name);
109
+ const prev = prevKey === undefined ? undefined : this.buckets.get(prevKey);
110
+ if (prevKey !== undefined && prev) {
111
+ const i = prev.findIndex((m) => m.name === name);
112
+ if (i >= 0) prev.splice(i, 1);
113
+ if (!prev.length) { this.buckets.delete(prevKey); this.unsorted.delete(prevKey); }
114
+ }
115
+ let bucket = this.buckets.get(k.key);
116
+ if (!bucket) { bucket = []; this.buckets.set(k.key, bucket); }
117
+ bucket.push({ name, nums: k.nums, rel: k.rel });
118
+ this.bucketOf.set(name, k.key);
119
+ if (bucket.length > 1) this.unsorted.add(k.key);
120
+ }
121
+ add(name: string, content: Buffer | string): void { this.addKey(name, contentKey(name, content)); }
122
+
123
+ /** Every cluster, singletons included. Each member joins the first cluster (representative order) whose
124
+ * representative is within tolerance of it. `find` answers "the first member within tolerance", which is
125
+ * that same representative unless members chain apart by more than the tolerance (never seen: the real
126
+ * drift is 0.004, the tolerance 0.01). */
127
+ groups(): ContentGroup[] { return this.clustersOf(null); }
128
+
129
+ /** groups(), limited to the buckets holding `names` — the same clusters, in the same order, as groups() gives for
130
+ * them. Clustering is quadratic in a bucket's size, so a pull asking about its own few files does not pay for
131
+ * every bucket of a directory every screen ever pulled shares. A name not in the index adds nothing. */
132
+ groupsOf(names: Iterable<string>): ContentGroup[] {
133
+ const keys = new Set<string>();
134
+ for (const n of names) { const k = this.bucketOf.get(n); if (k !== undefined) keys.add(k); }
135
+ return keys.size ? this.clustersOf(keys) : [];
136
+ }
137
+
138
+ private clustersOf(only: ReadonlySet<string> | null): ContentGroup[] {
139
+ const out: ContentGroup[] = [];
140
+ for (const key of this.buckets.keys()) {
141
+ if (only && !only.has(key)) continue;
142
+ const members = this.sortedBucket(key) ?? [];
143
+ const clusters: Member[][] = [];
144
+ for (const m of members) {
145
+ const c = clusters.find((cl) => { const rep = cl[0]; return rep !== undefined && within(rep, m, this.tol); });
146
+ if (c) c.push(m); else clusters.push([m]);
147
+ }
148
+ for (const cl of clusters) {
149
+ const rep = cl[0];
150
+ if (rep) out.push({ hash: groupHash(key, rep), files: cl.map((m) => m.name) });
151
+ }
152
+ }
153
+ return out;
154
+ }
155
+ }
156
+
157
+ // ---- FileKeyCache: a file's ContentKey, remembered across pulls in one process
158
+ //
159
+ // Every screen pull builds the ContentIndex over the whole shared assets/ directory, so a process that pulls
160
+ // many screens would otherwise re-read and re-fingerprint every file in it on every pull. file-memo.ts says
161
+ // when a remembered key may be used.
162
+
163
+ export class FileKeyCache extends FileMemo<ContentKey> {
164
+ /** contentKey(name, <the bytes of `file`>), or undefined when the file cannot be read. */
165
+ keyOf(file: string, name: string): ContentKey | undefined {
166
+ return this.get(file, () => {
167
+ let bytes: Buffer;
168
+ try { bytes = fs.readFileSync(file); } catch { return undefined; }
169
+ return contentKey(name, bytes);
170
+ });
171
+ }
172
+ }
173
+
174
+ /** The process-wide cache buildContentIndex (write-out.ts) reads assets/ through. */
175
+ export const fileKeyCache = new FileKeyCache();