figma-cache-toolchain 2.0.5 → 2.0.7

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.
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * Generate iconInsets.<cacheKey>.generated.ts for each batch item.
6
+ * Output directory defaults to the target component directory (dirname(target)).
7
+ *
8
+ * Usage:
9
+ * node scripts/generate-icon-insets-from-batch.cjs --batch=./figma-e2e-batch.json
10
+ */
11
+
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+ const { execSync } = require("child_process");
15
+
16
+ const ROOT = process.cwd();
17
+ const DEFAULT_INDEX_ABS = path.join(ROOT, "figma-cache", "index.json");
18
+
19
+ function normalizeNodeId(input) {
20
+ const value = String(input || "").trim();
21
+ if (!value) return "";
22
+ return value.includes(":") ? value : value.replace(/-/g, ":");
23
+ }
24
+
25
+ function cacheKeyFromItem(item) {
26
+ const fileKey = String(item && item.fileKey ? item.fileKey : "").trim();
27
+ const nodeId = String(item && item.nodeId ? item.nodeId : "").trim();
28
+ if (!fileKey || !nodeId) return "";
29
+ return `${fileKey}#${normalizeNodeId(nodeId)}`;
30
+ }
31
+
32
+ function normalizeCacheKey(input) {
33
+ const value = String(input || "").trim();
34
+ if (!value) return "";
35
+ const parts = value.split("#");
36
+ if (parts.length !== 2) return value;
37
+ return `${parts[0]}#${normalizeNodeId(parts[1])}`;
38
+ }
39
+
40
+ function toRelatedCacheKeys(item) {
41
+ const raw = item && item.relatedCacheKeys;
42
+ if (!raw) return [];
43
+ if (Array.isArray(raw)) return raw.map(normalizeCacheKey).filter(Boolean);
44
+ if (typeof raw === "string") {
45
+ return raw
46
+ .split(",")
47
+ .map((s) => normalizeCacheKey(s))
48
+ .filter(Boolean);
49
+ }
50
+ return [];
51
+ }
52
+
53
+ function extractCacheKeyFromFigmaUrl(url) {
54
+ const input = String(url || "").trim();
55
+ if (!input) return "";
56
+ // Matches:
57
+ // https://www.figma.com/design/<fileKey>/... ?node-id=9277-28552
58
+ // https://www.figma.com/file/<fileKey>/... ?node-id=9277%3A28552
59
+ const fileKeyMatch = input.match(/figma\.com\/(?:design|file)\/([^/]+)/i);
60
+ const nodeIdMatch = input.match(/[?&]node-id=([^&]+)/i);
61
+ if (!fileKeyMatch || !nodeIdMatch) return "";
62
+ const fileKey = String(fileKeyMatch[1] || "").trim();
63
+ const decodedNode = decodeURIComponent(String(nodeIdMatch[1] || "").trim());
64
+ const nodeId = normalizeNodeId(decodedNode);
65
+ if (!fileKey || !nodeId) return "";
66
+ return `${fileKey}#${nodeId}`;
67
+ }
68
+
69
+ function toRelatedUrls(item) {
70
+ const raw = item && item.relatedUrls;
71
+ if (!raw) return [];
72
+ if (Array.isArray(raw)) return raw.map((u) => String(u || "").trim()).filter(Boolean);
73
+ if (typeof raw === "string") {
74
+ return raw
75
+ .split(",")
76
+ .map((s) => String(s || "").trim())
77
+ .filter(Boolean);
78
+ }
79
+ return [];
80
+ }
81
+
82
+ function safeReadJson(absPath) {
83
+ try {
84
+ return JSON.parse(fs.readFileSync(absPath, "utf8"));
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ function relatedFromFlowIndex(cacheKey) {
91
+ const enabled = process.env.FIGMA_UI_RELATED_FROM_FLOW !== "0";
92
+ if (!enabled) return [];
93
+ const index = safeReadJson(DEFAULT_INDEX_ABS);
94
+ if (!index || typeof index !== "object" || !index.flows) return [];
95
+ const flows = index.flows;
96
+ const related = new Set();
97
+ Object.keys(flows).forEach((flowId) => {
98
+ const flow = flows[flowId];
99
+ const nodes = flow && Array.isArray(flow.nodes) ? flow.nodes : [];
100
+ if (!nodes.includes(cacheKey)) return;
101
+ nodes.forEach((k) => {
102
+ if (k && k !== cacheKey) related.add(normalizeCacheKey(k));
103
+ });
104
+ });
105
+ return Array.from(related).filter(Boolean);
106
+ }
107
+
108
+ function resolveTargetAbs(rawTarget) {
109
+ const trimmed = String(rawTarget || "").trim();
110
+ if (!trimmed) return "";
111
+ return path.isAbsolute(trimmed) ? path.normalize(trimmed) : path.join(ROOT, trimmed);
112
+ }
113
+
114
+ function parseArgs(argv) {
115
+ const out = {
116
+ batch: path.join(ROOT, "figma-e2e-batch.json"),
117
+ maxBox: 24,
118
+ toolchainGenerateScript: path.join(__dirname, "generate-icon-insets.cjs"),
119
+ };
120
+ argv.slice(2).forEach((arg) => {
121
+ if (arg.startsWith("--batch=")) out.batch = arg.split("=").slice(1).join("=").trim();
122
+ if (arg.startsWith("--max-box=")) out.maxBox = Number(arg.split("=").slice(1).join("=").trim());
123
+ });
124
+ return out;
125
+ }
126
+
127
+ function main() {
128
+ const args = parseArgs(process.argv);
129
+ const batchAbs = path.isAbsolute(args.batch) ? args.batch : path.join(ROOT, args.batch);
130
+ if (!fs.existsSync(batchAbs)) {
131
+ console.error(`[generate-icon-insets-from-batch] batch not found: ${batchAbs}`);
132
+ process.exit(2);
133
+ }
134
+ const payload = JSON.parse(fs.readFileSync(batchAbs, "utf8"));
135
+ if (!Array.isArray(payload) || payload.length === 0) {
136
+ console.error("[generate-icon-insets-from-batch] batch must be a non-empty array");
137
+ process.exit(2);
138
+ }
139
+
140
+ const genAbs = args.toolchainGenerateScript;
141
+ if (!fs.existsSync(genAbs)) {
142
+ console.error(`[generate-icon-insets-from-batch] missing generator: ${genAbs}`);
143
+ process.exit(2);
144
+ }
145
+
146
+ const outputs = [];
147
+ payload.forEach((item, idx) => {
148
+ const cacheKey = String(item && (item.cacheKey || cacheKeyFromItem(item)) || "").trim();
149
+ const targetAbs = resolveTargetAbs(item && item.target);
150
+ const relatedCacheKeysExplicit = toRelatedCacheKeys(item);
151
+ const relatedCacheKeysFromUrls = toRelatedUrls(item)
152
+ .map(extractCacheKeyFromFigmaUrl)
153
+ .map(normalizeCacheKey)
154
+ .filter(Boolean);
155
+ const relatedCacheKeysFromFlow = relatedFromFlowIndex(cacheKey);
156
+ const relatedCacheKeys = Array.from(
157
+ new Set([...relatedCacheKeysExplicit, ...relatedCacheKeysFromUrls, ...relatedCacheKeysFromFlow])
158
+ ).filter(Boolean);
159
+ if (!cacheKey) {
160
+ console.error(`[generate-icon-insets-from-batch] case[${idx}] missing cacheKey or (fileKey+nodeId)`);
161
+ process.exit(2);
162
+ }
163
+ if (!targetAbs) {
164
+ console.error(`[generate-icon-insets-from-batch] case[${idx}] missing target`);
165
+ process.exit(2);
166
+ }
167
+ const targetDir = path.dirname(targetAbs);
168
+ const rawArgs = [cacheKey, ...relatedCacheKeys]
169
+ .map((ck) => {
170
+ const [fk, nid] = String(ck).split("#");
171
+ const safeNodeDir = String(nid || "").replace(/:/g, "-");
172
+ const rawAbs = path.join(ROOT, "figma-cache", "files", fk, "nodes", safeNodeDir, "raw.json");
173
+ if (!fs.existsSync(rawAbs)) {
174
+ console.error(`[generate-icon-insets-from-batch] raw.json not found for ${ck}: ${rawAbs}`);
175
+ process.exit(2);
176
+ }
177
+ return `--raw="${rawAbs}"`;
178
+ })
179
+ .join(" ");
180
+
181
+ execSync(`node "${genAbs}" ${rawArgs} --out-dir="${targetDir}" --cacheKey="${cacheKey}" --max-box=${args.maxBox}`, {
182
+ cwd: ROOT,
183
+ stdio: "pipe",
184
+ });
185
+ outputs.push({ cacheKey, outDir: targetDir });
186
+ });
187
+
188
+ console.log(
189
+ `[generate-icon-insets-from-batch] ok (${outputs.length} cases)`
190
+ );
191
+ }
192
+
193
+ main();
194
+
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * Toolchain-provided icon inset exporter.
6
+ * Reads raw.json.iconMetrics and emits a TS mapping file for machine consumption.
7
+ */
8
+
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+
12
+ function parseArgs(argv) {
13
+ const out = {
14
+ raw: [],
15
+ out: "",
16
+ maxBox: 24,
17
+ cacheKey: "",
18
+ outDir: "",
19
+ };
20
+ argv.slice(2).forEach((arg) => {
21
+ if (arg.startsWith("--raw=")) out.raw.push(arg.split("=").slice(1).join("=").trim());
22
+ if (arg.startsWith("--out=")) out.out = arg.split("=").slice(1).join("=").trim();
23
+ if (arg.startsWith("--out-dir=")) out.outDir = arg.split("=").slice(1).join("=").trim();
24
+ if (arg.startsWith("--cacheKey=")) out.cacheKey = arg.split("=").slice(1).join("=").trim();
25
+ if (arg.startsWith("--max-box=")) out.maxBox = Number(arg.split("=").slice(1).join("=").trim());
26
+ });
27
+ return out;
28
+ }
29
+
30
+ function readJson(absPath) {
31
+ return JSON.parse(fs.readFileSync(absPath, "utf8"));
32
+ }
33
+
34
+ function formatNumber(n) {
35
+ const num = Number(n);
36
+ if (!Number.isFinite(num)) return "0";
37
+ return String(Number(num.toFixed(4))).replace(/\.0+$/, "");
38
+ }
39
+
40
+ function buildTs(mapping) {
41
+ const lines = [];
42
+ lines.push(`export type InsetsPx = { top: number; right: number; bottom: number; left: number };`);
43
+ lines.push("");
44
+ lines.push("/**");
45
+ lines.push(" * AUTO-GENERATED.");
46
+ lines.push(" * Source: raw.json.iconMetrics (derived from get_design_context inset percentages)");
47
+ lines.push(" */");
48
+ lines.push("export const ICON_INSETS_PX: Record<string, InsetsPx> = {");
49
+ Object.keys(mapping)
50
+ .sort()
51
+ .forEach((key) => {
52
+ const v = mapping[key];
53
+ lines.push(
54
+ ` "${key}": { top: ${formatNumber(v.top)}, right: ${formatNumber(v.right)}, bottom: ${formatNumber(
55
+ v.bottom
56
+ )}, left: ${formatNumber(v.left)} },`
57
+ );
58
+ });
59
+ lines.push("};");
60
+ lines.push("");
61
+ return `${lines.join("\n")}\n`;
62
+ }
63
+
64
+ function main() {
65
+ const args = parseArgs(process.argv);
66
+ if (!args.raw.length || (!args.out && !(args.outDir && args.cacheKey))) {
67
+ console.error(
68
+ "Usage: node scripts/generate-icon-insets.cjs --raw=<raw.json> [--raw=<raw2.json> ...] (--out=<out.ts> | --out-dir=<dir> --cacheKey=<cacheKey>) [--max-box=24]"
69
+ );
70
+ process.exit(2);
71
+ }
72
+ const computedOut = args.out
73
+ ? args.out
74
+ : path.join(
75
+ args.outDir,
76
+ `iconInsets.${String(args.cacheKey || "")
77
+ .replace(/[^a-zA-Z0-9._-]+/g, "_")
78
+ .slice(0, 120)}.generated.ts`
79
+ );
80
+ const outAbs = path.isAbsolute(computedOut) ? computedOut : path.join(process.cwd(), computedOut);
81
+
82
+ const mapping = {};
83
+ args.raw.forEach((rawPath) => {
84
+ const rawAbs = path.isAbsolute(rawPath) ? rawPath : path.join(process.cwd(), rawPath);
85
+ const data = readJson(rawAbs);
86
+ const list = Array.isArray(data && data.iconMetrics) ? data.iconMetrics : [];
87
+ list.forEach((item) => {
88
+ if (!item || typeof item !== "object") return;
89
+ const nodeId = String(item.nodeId || "").trim();
90
+ const boxPx = Number(item.boxPx);
91
+ const insetPx = item.insetPx;
92
+ if (!nodeId) return;
93
+ if (!Number.isFinite(boxPx) || boxPx <= 0 || boxPx > args.maxBox) return;
94
+ if (!insetPx || typeof insetPx !== "object") return;
95
+ // First write wins to keep stable precedence: primary cacheKey first, related next.
96
+ if (mapping[nodeId]) return;
97
+ mapping[nodeId] = {
98
+ top: Number(insetPx.top || 0),
99
+ right: Number(insetPx.right || 0),
100
+ bottom: Number(insetPx.bottom || 0),
101
+ left: Number(insetPx.left || 0),
102
+ };
103
+ });
104
+ });
105
+
106
+ fs.mkdirSync(path.dirname(outAbs), { recursive: true });
107
+ fs.writeFileSync(outAbs, buildTs(mapping), "utf8");
108
+ console.log(`[generate-icon-insets] wrote ${Object.keys(mapping).length} entries -> ${outAbs}`);
109
+ }
110
+
111
+ main();
112
+
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * Import MCP raw evidence files into figma-cache node directory and generate a manifest.
6
+ *
7
+ * This is the toolchain-friendly way to implement: "call MCP once -> cache evidence".
8
+ *
9
+ * Usage:
10
+ * node scripts/import-mcp-raw-evidence.cjs \
11
+ * --cacheKey=<fileKey#nodeId> \
12
+ * --design-context=<path/to/get_design_context.txt> \
13
+ * --metadata=<path/to/get_metadata.txt> \
14
+ * --variable-defs=<path/to/get_variable_defs.json>
15
+ *
16
+ * Notes:
17
+ * - Writes to: figma-cache/files/<fileKey>/nodes/<safeNodeId>/mcp-raw/
18
+ * - Generates: mcp-raw-manifest.json with sha256 + byte sizes
19
+ */
20
+
21
+ const crypto = require("crypto");
22
+ const fs = require("fs");
23
+ const path = require("path");
24
+
25
+ function sha256Utf8(text) {
26
+ return crypto.createHash("sha256").update(String(text || ""), "utf8").digest("hex");
27
+ }
28
+
29
+ function sizeUtf8(text) {
30
+ return Buffer.byteLength(String(text || ""), "utf8");
31
+ }
32
+
33
+ function readUtf8(absPath) {
34
+ return fs.readFileSync(absPath, "utf8");
35
+ }
36
+
37
+ function resolveAbs(p) {
38
+ const v = String(p || "").trim();
39
+ if (!v) return "";
40
+ return path.isAbsolute(v) ? v : path.join(process.cwd(), v);
41
+ }
42
+
43
+ function normalizeNodeId(input) {
44
+ const value = String(input || "").trim();
45
+ if (!value) return "";
46
+ return value.includes(":") ? value : value.replace(/-/g, ":");
47
+ }
48
+
49
+ function parseArgs(argv) {
50
+ const out = {
51
+ cacheKey: "",
52
+ designContext: "",
53
+ metadata: "",
54
+ variableDefs: "",
55
+ };
56
+ argv.slice(2).forEach((arg) => {
57
+ if (arg.startsWith("--cacheKey=")) out.cacheKey = arg.split("=").slice(1).join("=").trim();
58
+ if (arg.startsWith("--design-context="))
59
+ out.designContext = arg.split("=").slice(1).join("=").trim();
60
+ if (arg.startsWith("--metadata=")) out.metadata = arg.split("=").slice(1).join("=").trim();
61
+ if (arg.startsWith("--variable-defs="))
62
+ out.variableDefs = arg.split("=").slice(1).join("=").trim();
63
+ });
64
+ return out;
65
+ }
66
+
67
+ function main() {
68
+ const args = parseArgs(process.argv);
69
+ if (!args.cacheKey || !args.designContext || !args.metadata || !args.variableDefs) {
70
+ console.error(
71
+ "Usage: node scripts/import-mcp-raw-evidence.cjs --cacheKey=<fileKey#nodeId> --design-context=<txt> --metadata=<txt> --variable-defs=<json>"
72
+ );
73
+ process.exit(2);
74
+ }
75
+
76
+ const cacheKey = String(args.cacheKey).trim();
77
+ const [fileKey, nodeIdRaw] = cacheKey.split("#");
78
+ const nodeId = normalizeNodeId(nodeIdRaw);
79
+ if (!fileKey || !nodeId) {
80
+ console.error(`[import-mcp-raw-evidence] invalid cacheKey: ${cacheKey}`);
81
+ process.exit(2);
82
+ }
83
+
84
+ const safeNodeDir = nodeId.replace(/:/g, "-");
85
+ const mcpRawDir = path.join(
86
+ process.cwd(),
87
+ "figma-cache",
88
+ "files",
89
+ fileKey,
90
+ "nodes",
91
+ safeNodeDir,
92
+ "mcp-raw"
93
+ );
94
+ fs.mkdirSync(mcpRawDir, { recursive: true });
95
+
96
+ const srcDesign = resolveAbs(args.designContext);
97
+ const srcMeta = resolveAbs(args.metadata);
98
+ const srcVars = resolveAbs(args.variableDefs);
99
+ if (!fs.existsSync(srcDesign) || !fs.existsSync(srcMeta) || !fs.existsSync(srcVars)) {
100
+ console.error("[import-mcp-raw-evidence] missing input file(s)");
101
+ process.exit(2);
102
+ }
103
+
104
+ const files = {
105
+ get_design_context: "mcp-raw-get-design-context.txt",
106
+ get_metadata: "mcp-raw-get-metadata.txt",
107
+ get_variable_defs: "mcp-raw-get-variable-defs.json",
108
+ };
109
+
110
+ const contents = {
111
+ get_design_context: readUtf8(srcDesign),
112
+ get_metadata: readUtf8(srcMeta),
113
+ get_variable_defs: readUtf8(srcVars),
114
+ };
115
+
116
+ fs.writeFileSync(path.join(mcpRawDir, files.get_design_context), contents.get_design_context, "utf8");
117
+ fs.writeFileSync(path.join(mcpRawDir, files.get_metadata), contents.get_metadata, "utf8");
118
+ fs.writeFileSync(path.join(mcpRawDir, files.get_variable_defs), contents.get_variable_defs, "utf8");
119
+
120
+ const manifest = {
121
+ mcpServer: "plugin-figma-figma",
122
+ fileKey,
123
+ nodeId,
124
+ files,
125
+ fileHashes: Object.fromEntries(Object.entries(contents).map(([k, v]) => [k, sha256Utf8(v)])),
126
+ fileSizes: Object.fromEntries(Object.entries(contents).map(([k, v]) => [k, sizeUtf8(v)])),
127
+ };
128
+
129
+ fs.writeFileSync(
130
+ path.join(mcpRawDir, "mcp-raw-manifest.json"),
131
+ `${JSON.stringify(manifest, null, 2)}\n`,
132
+ "utf8"
133
+ );
134
+
135
+ console.log(
136
+ `[import-mcp-raw-evidence] ok -> ${path.join(mcpRawDir, "mcp-raw-manifest.json")}`
137
+ );
138
+ }
139
+
140
+ main();
141
+
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * Merge figma-geometry-metrics.json into raw.json as layoutMetrics[].
6
+ *
7
+ * Geometry file shape:
8
+ * {
9
+ * "version": 1,
10
+ * "source": "figma_plugin_absoluteBoundingBox",
11
+ * "metrics": [ { "id": "...", "kind": "spacer_between_nodes_y", "fromNodeId": "...", "toNodeId": "...", "spacerPx": 26, ... } ]
12
+ * }
13
+ *
14
+ * Usage:
15
+ * node scripts/merge-figma-geometry-metrics.cjs --raw=<raw.json> --geometry=<figma-geometry-metrics.json>
16
+ */
17
+
18
+ const fs = require("fs");
19
+ const path = require("path");
20
+ const { mergeLayoutMetricsFromGeometry } = require("../figma-cache/js/raw-derivatives");
21
+
22
+ function safeReadJson(absPath) {
23
+ try {
24
+ return JSON.parse(fs.readFileSync(absPath, "utf8"));
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function writeJson(absPath, value) {
31
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
32
+ fs.writeFileSync(absPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
33
+ }
34
+
35
+ function parseArgs(argv) {
36
+ const out = { raw: "", geometry: "" };
37
+ argv.slice(2).forEach((arg) => {
38
+ if (arg.startsWith("--raw=")) out.raw = arg.split("=").slice(1).join("=").trim();
39
+ if (arg.startsWith("--geometry=")) out.geometry = arg.split("=").slice(1).join("=").trim();
40
+ });
41
+ return out;
42
+ }
43
+
44
+ function main() {
45
+ const args = parseArgs(process.argv);
46
+ const rawAbs = path.isAbsolute(args.raw) ? args.raw : path.join(process.cwd(), args.raw);
47
+ const geoAbs = path.isAbsolute(args.geometry) ? args.geometry : path.join(process.cwd(), args.geometry);
48
+ if (!args.raw || !args.geometry) {
49
+ console.error(
50
+ "Usage: node scripts/merge-figma-geometry-metrics.cjs --raw=<raw.json> --geometry=<figma-geometry-metrics.json>"
51
+ );
52
+ process.exit(2);
53
+ }
54
+ if (!fs.existsSync(rawAbs)) {
55
+ console.error(`[merge-figma-geometry-metrics] raw not found: ${rawAbs}`);
56
+ process.exit(2);
57
+ }
58
+ if (!fs.existsSync(geoAbs)) {
59
+ console.error(`[merge-figma-geometry-metrics] geometry not found: ${geoAbs}`);
60
+ process.exit(2);
61
+ }
62
+
63
+ const raw = safeReadJson(rawAbs);
64
+ const geo = safeReadJson(geoAbs);
65
+ if (!raw || typeof raw !== "object") {
66
+ console.error(`[merge-figma-geometry-metrics] invalid raw: ${rawAbs}`);
67
+ process.exit(2);
68
+ }
69
+ if (!geo || typeof geo !== "object" || !Array.isArray(geo.metrics)) {
70
+ console.error(`[merge-figma-geometry-metrics] invalid geometry (need .metrics[]): ${geoAbs}`);
71
+ process.exit(2);
72
+ }
73
+
74
+ mergeLayoutMetricsFromGeometry(raw, geo);
75
+ writeJson(rawAbs, raw);
76
+ console.log(
77
+ `[merge-figma-geometry-metrics] ok merged=${geo.metrics.length} -> ${rawAbs}`
78
+ );
79
+ }
80
+
81
+ main();
@@ -16,6 +16,23 @@ const DEFAULT_MIN_SCORE = 85;
16
16
  const DEFAULT_RECIPES_DIR = "figma-cache/adapters/recipes";
17
17
  const FAIL_EXIT_CODE = 2;
18
18
 
19
+ function parseBoolEnv(value, fallback) {
20
+ if (value == null) return fallback;
21
+ const v = String(value).trim().toLowerCase();
22
+ if (["1", "true", "yes", "on"].includes(v)) return true;
23
+ if (["0", "false", "no", "off"].includes(v)) return false;
24
+ return fallback;
25
+ }
26
+
27
+ function filterRemoteFigmaAssetRefs(input) {
28
+ const text = String(input || "");
29
+ // Mask Figma MCP asset URLs (often require auth; non-deterministic in runtime).
30
+ // This keeps audits stable when teams choose to not ship remote figma assets.
31
+ return text
32
+ .replace(/https:\/\/www\.figma\.com\/api\/mcp\/asset\/[a-z0-9-]+/gi, "__FIGMA_MCP_ASSET__")
33
+ .replace(/\bimg[A-Za-z0-9_]*\s*=\s*['"]https:\/\/www\.figma\.com\/api\/mcp\/asset\/[a-z0-9-]+['"]/gi, "img__=__FIGMA_MCP_ASSET__");
34
+ }
35
+
19
36
  function normalizeSlash(input) {
20
37
  return String(input || "").replace(/\\/g, "/");
21
38
  }
@@ -51,6 +68,7 @@ function parseArgs(argv) {
51
68
  reportPath: DEFAULT_REPORT_PATH,
52
69
  minScore: DEFAULT_MIN_SCORE,
53
70
  recipesDir: DEFAULT_RECIPES_DIR,
71
+ filterRemoteFigmaAssets: parseBoolEnv(process.env.FIGMA_UI_FILTER_REMOTE_FIGMA_ASSETS, true),
54
72
  unknownArgs: [],
55
73
  };
56
74
 
@@ -80,6 +98,10 @@ function parseArgs(argv) {
80
98
  options.recipesDir = arg.split("=").slice(1).join("=").trim() || DEFAULT_RECIPES_DIR;
81
99
  return;
82
100
  }
101
+ if (arg === "--no-filter-remote-figma-assets") {
102
+ options.filterRemoteFigmaAssets = false;
103
+ return;
104
+ }
83
105
  options.unknownArgs.push(arg);
84
106
  });
85
107
 
@@ -170,7 +192,7 @@ function detectMatchedRecipes(recipes, contextText, statesInCache) {
170
192
  }
171
193
 
172
194
  function scoreItem(params) {
173
- const { cacheKey, item, contract, targetCode, recipes } = params;
195
+ const { cacheKey, item, contract, targetCode, recipes, options } = params;
174
196
  const blocking = [];
175
197
  const warnings = [];
176
198
  const diffs = [];
@@ -261,9 +283,13 @@ function scoreItem(params) {
261
283
  }
262
284
 
263
285
  const hasTodo = normalizedFacts.hasPlaceholder;
286
+ const effectiveTargetCode =
287
+ options && options.filterRemoteFigmaAssets
288
+ ? filterRemoteFigmaAssetRefs(targetCode)
289
+ : String(targetCode || "");
264
290
  const matchedRecipes = detectMatchedRecipes(
265
291
  recipes,
266
- `${specText}\n${stateMapText}\n${JSON.stringify(rawJson || {})}\n${targetCode}`,
292
+ `${specText}\n${stateMapText}\n${JSON.stringify(rawJson || {})}\n${effectiveTargetCode}`,
267
293
  statesInCache
268
294
  );
269
295
  if (!matchedRecipes.length) {
@@ -283,11 +309,11 @@ function scoreItem(params) {
283
309
  : textFacts.length;
284
310
  const tokenCodeHits = hasTargetCode
285
311
  ? tokenFacts.filter((fact) =>
286
- targetCode.toUpperCase().includes(String(normalizeHexColor(fact.value || "")).toUpperCase())
312
+ effectiveTargetCode.toUpperCase().includes(String(normalizeHexColor(fact.value || "")).toUpperCase())
287
313
  ).length
288
314
  : tokenFacts.length;
289
315
  const stateCodeHits = hasTargetCode
290
- ? statesInCache.filter((state) => targetCode.toLowerCase().includes(state)).length
316
+ ? statesInCache.filter((state) => effectiveTargetCode.toLowerCase().includes(state)).length
291
317
  : statesInCache.length;
292
318
 
293
319
  const layoutScore = entryReady ? 100 : 20;
@@ -361,6 +387,7 @@ function run() {
361
387
  contract,
362
388
  targetCode,
363
389
  recipes,
390
+ options,
364
391
  })
365
392
  );
366
393
 
@@ -409,6 +436,7 @@ function run() {
409
436
  contractPath: normalizeSlash(contractPath),
410
437
  reportPath: normalizeSlash(reportPath),
411
438
  recipesDir: normalizeSlash(recipesDir),
439
+ filterRemoteFigmaAssets: options.filterRemoteFigmaAssets,
412
440
  },
413
441
  blocking,
414
442
  warnings,