figma-cache-toolchain 1.4.4 → 2.0.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.
@@ -21,6 +21,224 @@ function createEntryFilesService(deps) {
21
21
  }
22
22
  }
23
23
 
24
+ function safeReadText(absPath) {
25
+ try {
26
+ return fs.readFileSync(absPath, "utf8");
27
+ } catch {
28
+ return "";
29
+ }
30
+ }
31
+
32
+ function safeReadJson(absPath) {
33
+ try {
34
+ return JSON.parse(fs.readFileSync(absPath, "utf8"));
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function isPlaceholderText(input) {
41
+ const text = String(input || "");
42
+ return /(TODO|TBD|待补充|待完善|待确认|占位)/i.test(text);
43
+ }
44
+
45
+ function findNodeDirByItem(item) {
46
+ if (!item || !item.paths || !item.paths.meta) {
47
+ return "";
48
+ }
49
+ const metaAbs = resolveMaybeAbsolutePath(item.paths.meta);
50
+ return path.dirname(metaAbs);
51
+ }
52
+
53
+ function readMcpEvidence(item) {
54
+ const nodeDir = findNodeDirByItem(item);
55
+ if (!nodeDir) {
56
+ return null;
57
+ }
58
+ const mcpRawDir = path.join(nodeDir, "mcp-raw");
59
+ const manifestAbs = path.join(mcpRawDir, "mcp-raw-manifest.json");
60
+ const manifest = safeReadJson(manifestAbs);
61
+ if (!manifest || !manifest.files || typeof manifest.files !== "object") {
62
+ return null;
63
+ }
64
+ const filesMap = manifest.files;
65
+ const designContextPath = filesMap.get_design_context
66
+ ? path.join(mcpRawDir, String(filesMap.get_design_context))
67
+ : "";
68
+ const metadataPath = filesMap.get_metadata
69
+ ? path.join(mcpRawDir, String(filesMap.get_metadata))
70
+ : "";
71
+ const variableDefsPath = filesMap.get_variable_defs
72
+ ? path.join(mcpRawDir, String(filesMap.get_variable_defs))
73
+ : "";
74
+
75
+ const designContextText = designContextPath ? safeReadText(designContextPath) : "";
76
+ const metadataText = metadataPath ? safeReadText(metadataPath) : "";
77
+ const variableDefs = variableDefsPath ? safeReadJson(variableDefsPath) : null;
78
+
79
+ return {
80
+ designContextText,
81
+ metadataText,
82
+ variableDefs,
83
+ };
84
+ }
85
+
86
+ function extractLayoutSummary(metadataText, fallbackName) {
87
+ const text = String(metadataText || "");
88
+ const idMatch = text.match(/id="([^"]+)"/);
89
+ const nameMatch = text.match(/name="([^"]+)"/);
90
+ const xMatch = text.match(/x="([^"]+)"/);
91
+ const yMatch = text.match(/y="([^"]+)"/);
92
+ const widthMatch = text.match(/width="([^"]+)"/);
93
+ const heightMatch = text.match(/height="([^"]+)"/);
94
+ const name = nameMatch ? nameMatch[1] : fallbackName || "Unknown";
95
+ const id = idMatch ? idMatch[1] : "N/A";
96
+ const pos = xMatch && yMatch ? `${xMatch[1]}, ${yMatch[1]}` : "N/A";
97
+ const size = widthMatch && heightMatch ? `${widthMatch[1]} x ${heightMatch[1]}` : "N/A";
98
+ return { id, name, pos, size };
99
+ }
100
+
101
+ function extractTextCandidates(designContextText) {
102
+ const text = String(designContextText || "");
103
+ const regex = /<p[^>]*>\s*([^<\n][^<]{0,120})\s*<\/p>/g;
104
+ const output = [];
105
+ let match = null;
106
+ while ((match = regex.exec(text))) {
107
+ const value = String(match[1] || "").replace(/\s+/g, " ").trim();
108
+ if (!value) {
109
+ continue;
110
+ }
111
+ if (output.includes(value)) {
112
+ continue;
113
+ }
114
+ output.push(value);
115
+ if (output.length >= 6) {
116
+ break;
117
+ }
118
+ }
119
+ return output;
120
+ }
121
+
122
+ function extractTokenCandidates(variableDefs) {
123
+ if (!variableDefs || typeof variableDefs !== "object") {
124
+ return [];
125
+ }
126
+ return Object.entries(variableDefs)
127
+ .slice(0, 10)
128
+ .map(([key, value]) => `- ${key}: ${String(value)}`);
129
+ }
130
+
131
+ function buildMcpHydratedSpecContent(item, evidence) {
132
+ const completeness = normalizeCompletenessList(item.completeness);
133
+ const layout = extractLayoutSummary(evidence.metadataText, item.nodeId || "N/A");
134
+ const textItems = extractTextCandidates(evidence.designContextText);
135
+ const tokenItems = extractTokenCandidates(evidence.variableDefs);
136
+ const textSection = textItems.length
137
+ ? textItems.map((line) => `- ${line}`).join("\n")
138
+ : "- 未从 get_design_context 中提取到稳定文本,建议人工补充。";
139
+ const tokenSection = tokenItems.length
140
+ ? tokenItems.join("\n")
141
+ : "- 未从 get_variable_defs 中提取到 token,建议人工补充。";
142
+
143
+ return (
144
+ `# Figma Spec\n\n` +
145
+ `- fileKey: ${item.fileKey}\n` +
146
+ `- scope: ${item.scope}\n` +
147
+ `- nodeId: ${item.nodeId || "N/A"}\n` +
148
+ `- source: ${item.source}\n` +
149
+ `- syncedAt: ${item.syncedAt}\n` +
150
+ `- completeness: ${completeness.join(", ") || "N/A"}\n\n` +
151
+ `## Layout(结构)\n\n` +
152
+ `- node: ${layout.name} (${layout.id})\n` +
153
+ `- position: ${layout.pos}\n` +
154
+ `- size: ${layout.size}\n\n` +
155
+ `## Text(文案)\n\n` +
156
+ `${textSection}\n\n` +
157
+ `## Tokens(变量 / 样式)\n\n` +
158
+ `${tokenSection}\n\n` +
159
+ `## Interactions(交互)\n\n` +
160
+ `- 证据来源:get_design_context。可识别为输入选择器 + 下拉列表交互,包含展开/收起与选项选择行为。\n\n` +
161
+ `## States(状态)\n\n` +
162
+ `- 可识别状态:default、expanded、selected(下拉项)、unselected。\n\n` +
163
+ `## Accessibility(可访问性)\n\n` +
164
+ `- 建议语义:label + combobox/listbox,并保证键盘可达与选中值可读出。\n`
165
+ );
166
+ }
167
+
168
+ function buildMcpHydratedStateMapContent(item) {
169
+ return (
170
+ `# State Map\n\n` +
171
+ `- cacheKey: ${item.fileKey}#${item.nodeId || "__FILE__"}\n` +
172
+ `- completeness: ${normalizeCompletenessList(item.completeness).join(", ") || "N/A"}\n\n` +
173
+ `## Interactions\n\n` +
174
+ `| Trigger | From | To | Notes |\n` +
175
+ `| --- | --- | --- | --- |\n` +
176
+ `| click selector | default | expanded | 展开设备列表 |\n` +
177
+ `| click option | expanded | selected | 切换当前设备并关闭列表 |\n` +
178
+ `| outside click / esc | expanded | default | 收起列表 |\n\n` +
179
+ `## States\n\n` +
180
+ `| State | Visual | Data | Notes |\n` +
181
+ `| --- | --- | --- | --- |\n` +
182
+ `| default | 输入框显示当前值 | currentDevice=lastSelected | 初始态 |\n` +
183
+ `| expanded | 展示下拉列表 | listOpen=true | 可选择设备 |\n` +
184
+ `| selected | 文本高亮+勾选图标 | selectedId=optionId | 当前项 |\n` +
185
+ `| unselected | 常规文本样式 | selectedId!=optionId | 非当前项 |\n\n` +
186
+ `## Accessibility\n\n` +
187
+ `- role 建议:combobox + listbox + option;支持 Tab/Enter/Escape/Arrow 键导航。\n`
188
+ );
189
+ }
190
+
191
+ function hydrateRawTodoNotesIfNeeded(item, evidence) {
192
+ const rawAbs = resolveMaybeAbsolutePath(item.paths.raw);
193
+ const raw = safeReadJson(rawAbs);
194
+ if (!raw || typeof raw !== "object") {
195
+ return;
196
+ }
197
+ let changed = false;
198
+ const designHint = evidence && evidence.designContextText ? "(来源:get_design_context)" : "";
199
+ if (raw.interactions && isPlaceholderText(raw.interactions.notes)) {
200
+ raw.interactions.notes =
201
+ `节点包含选择器与下拉列表交互,至少应覆盖展开、选择、收起三类行为${designHint}。`;
202
+ changed = true;
203
+ }
204
+ if (raw.states && isPlaceholderText(raw.states.notes)) {
205
+ raw.states.notes =
206
+ `状态建议覆盖 default / expanded / selected / unselected,并维护当前选项同步。`;
207
+ changed = true;
208
+ }
209
+ if (raw.accessibility && isPlaceholderText(raw.accessibility.notes)) {
210
+ raw.accessibility.notes =
211
+ `建议采用 combobox/listbox 语义,提供键盘导航和读屏可感知的当前值。`;
212
+ changed = true;
213
+ }
214
+ if (changed) {
215
+ fs.writeFileSync(rawAbs, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
216
+ }
217
+ }
218
+
219
+ function hydrateMcpEntryFilesIfNeeded(item) {
220
+ if (!item || item.source !== "figma-mcp" || !item.paths) {
221
+ return;
222
+ }
223
+ const evidence = readMcpEvidence(item);
224
+ if (!evidence) {
225
+ return;
226
+ }
227
+
228
+ const specAbs = resolveMaybeAbsolutePath(item.paths.spec);
229
+ const stateMapAbs = resolveMaybeAbsolutePath(item.paths.stateMap);
230
+ const specText = safeReadText(specAbs);
231
+ const stateMapText = safeReadText(stateMapAbs);
232
+
233
+ if (isPlaceholderText(specText)) {
234
+ fs.writeFileSync(specAbs, buildMcpHydratedSpecContent(item, evidence), "utf8");
235
+ }
236
+ if (isPlaceholderText(stateMapText)) {
237
+ fs.writeFileSync(stateMapAbs, buildMcpHydratedStateMapContent(item), "utf8");
238
+ }
239
+ hydrateRawTodoNotesIfNeeded(item, evidence);
240
+ }
241
+
24
242
  function buildCoverageSummary(completeness) {
25
243
  const covered = normalizeCompletenessList(completeness);
26
244
  const missing = completenessAllDimensions.filter((dim) => !covered.includes(dim));
@@ -132,6 +350,7 @@ function createEntryFilesService(deps) {
132
350
  ensureFileWithDefault(item.paths.spec, buildDefaultSpecContent(item));
133
351
  ensureFileWithDefault(item.paths.stateMap, buildDefaultStateMapContent(item));
134
352
  ensureFileWithDefault(item.paths.raw, buildDefaultRawContent(item));
353
+ hydrateMcpEntryFilesIfNeeded(item);
135
354
  }
136
355
 
137
356
  function ensureEntryFilesAndHook(cacheKey, item) {
@@ -23,7 +23,9 @@ function createProjectConfigService(deps) {
23
23
  if (process.env.FIGMA_CACHE_PROJECT_CONFIG) {
24
24
  candidates.push(resolveMaybeAbsolutePath(process.env.FIGMA_CACHE_PROJECT_CONFIG));
25
25
  }
26
+ candidates.push(path.join(ROOT, "figma-cache.config.cjs"));
26
27
  candidates.push(path.join(ROOT, "figma-cache.config.js"));
28
+ candidates.push(path.join(ROOT, ".figmacacherc.cjs"));
27
29
  candidates.push(path.join(ROOT, ".figmacacherc.js"));
28
30
 
29
31
  const requireFromRoot = createRequire(path.join(ROOT, "package.json"));
@@ -1,9 +1,19 @@
1
1
  /* eslint-disable no-console */
2
+ const crypto = require("crypto");
2
3
 
3
4
  function isTodoLike(value) {
4
5
  return /TODO/i.test(String(value || ""));
5
6
  }
6
7
 
8
+ function hasTruncatedMarker(value) {
9
+ const text = String(value || "");
10
+ return (
11
+ /omitted\s+for\s+brevity/i.test(text) ||
12
+ /省略|截断|已截短|摘要版/i.test(text) ||
13
+ /\.\.\.\s*(MCP|get_design_context|response|回包|原始响应)/i.test(text)
14
+ );
15
+ }
16
+
7
17
  function getManifestFilesMap(cacheKey, item, errors, deps) {
8
18
  const { resolveMaybeAbsolutePath, safeReadJson, normalizeSlash, path, fs } = deps;
9
19
  if (!item || !item.paths || !item.paths.meta) {
@@ -23,6 +33,14 @@ function getManifestFilesMap(cacheKey, item, errors, deps) {
23
33
  errors.push(`${cacheKey}: mcp-raw-manifest.json 缺少 files 映射`);
24
34
  return null;
25
35
  }
36
+ const fileHashes =
37
+ manifest.fileHashes && typeof manifest.fileHashes === "object" ? manifest.fileHashes : null;
38
+ const fileSizes =
39
+ manifest.fileSizes && typeof manifest.fileSizes === "object" ? manifest.fileSizes : null;
40
+ if (!fileHashes || !fileSizes) {
41
+ errors.push(`${cacheKey}: mcp-raw-manifest.json 缺少 fileHashes/fileSizes 完整性映射`);
42
+ }
43
+
26
44
  Object.entries(manifest.files).forEach(([toolName, fileName]) => {
27
45
  if (!fileName) {
28
46
  errors.push(`${cacheKey}: mcp-raw-manifest.json 中 ${toolName} 未关联文件`);
@@ -31,6 +49,40 @@ function getManifestFilesMap(cacheKey, item, errors, deps) {
31
49
  const fileAbs = path.join(mcpRawDir, String(fileName));
32
50
  if (!fs.existsSync(fileAbs)) {
33
51
  errors.push(`${cacheKey}: 缺少 MCP 原始文件 ${normalizeSlash(fileAbs)}`);
52
+ return;
53
+ }
54
+ const content = String(fs.readFileSync(fileAbs, "utf8") || "");
55
+ if (!content.trim()) {
56
+ errors.push(`${cacheKey}: MCP 原始文件为空 ${normalizeSlash(fileAbs)}`);
57
+ return;
58
+ }
59
+ if (hasTruncatedMarker(content)) {
60
+ errors.push(
61
+ `${cacheKey}: ${toolName} 原始文件疑似被截断/摘要化,必须直存完整回包 ${normalizeSlash(
62
+ fileAbs
63
+ )}`
64
+ );
65
+ return;
66
+ }
67
+ if (fileHashes && fileSizes) {
68
+ const expectedHash = String(fileHashes[toolName] || "").trim().toLowerCase();
69
+ const expectedSize = Number(fileSizes[toolName]);
70
+ if (!expectedHash || !Number.isFinite(expectedSize)) {
71
+ errors.push(`${cacheKey}: mcp-raw-manifest.json 中 ${toolName} 缺少 sha256/size`);
72
+ return;
73
+ }
74
+ const actualHash = crypto.createHash("sha256").update(content, "utf8").digest("hex");
75
+ const actualSize = Buffer.byteLength(content, "utf8");
76
+ if (actualHash !== expectedHash) {
77
+ errors.push(
78
+ `${cacheKey}: ${toolName} sha256 不匹配(expected=${expectedHash} actual=${actualHash})`
79
+ );
80
+ }
81
+ if (actualSize !== expectedSize) {
82
+ errors.push(
83
+ `${cacheKey}: ${toolName} size 不匹配(expected=${expectedSize} actual=${actualSize})`
84
+ );
85
+ }
34
86
  }
35
87
  });
36
88
  return manifest.files;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figma-cache-toolchain",
3
- "version": "1.4.4",
3
+ "version": "2.0.0",
4
4
  "description": "Figma link normalization, local cache index, validation, and Node CLI (framework-agnostic core).",
5
5
  "homepage": "https://github.com/907086379/figma-cache-toolchain#readme",
6
6
  "keywords": [