figma-cache-toolchain 1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) figma-cache-toolchain contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # figma-cache-toolchain
2
+
3
+ 从业务仓库拆出的 **Figma 本地缓存工具链**:链接标准化、`figma-cache/` 索引与校验、零运行时依赖的 Node CLI。Core 不绑定具体 UI 框架;Vue 2 + Vuetify 2 等适配通过项目根 `figma-cache.config.js` 的 `hooks.postEnsure` 与 `.cursor/rules/02-*-adapter.mdc` 完成。
4
+
5
+ ## 安装
6
+
7
+ 在发布到 npm 之前,请在本仓库根目录执行 `npm link`(或发布后使用你在 `package.json` 中声明的最终包名,例如 `npm i -D <你的作用域>/figma-cache-toolchain`)。**请勿假设**某个尚未发布的固定包名已存在于公共 registry。
8
+
9
+ 安装后,消费方项目的 `package.json` 可注册与下文「脚本」一致的 `npm run figma:cache:*` 命令。
10
+
11
+ ## 脚本(消费方)
12
+
13
+ 与 `figma-cache/migration-guide.md` 一致:作为 **依赖安装** 后,可用 `figma-cache config` 等形式(由 `node_modules/.bin` 解析)。在本仓库根目录开发时,`package.json` 使用 `node bin/figma-cache.js …`,避免仅含当前包、无其他依赖时本地 `.bin` 未生成导致命令找不到。
14
+
15
+ 若**不**通过 npm 安装、而是复制 `figma-cache/` 目录到业务仓库,可继续使用:
16
+
17
+ - `node figma-cache/figma-cache.js <子命令> ...`
18
+
19
+ ## 项目级配置与钩子
20
+
21
+ - 在消费项目根目录放置 `figma-cache.config.js` 或 `.figmacacherc.js`(加载顺序见 `figma-cache/figma-cache.js` 内注释:`FIGMA_CACHE_PROJECT_CONFIG` → `figma-cache.config.js` → `.figmacacherc.js`)。
22
+ - `hooks.postEnsure`:在 Core 完成 `ensure` 写入通用骨架后调用;钩子异常不会导致 `ensure` 失败退出。
23
+
24
+ ## 环境变量(节选)
25
+
26
+ 完整列表见 `figma-cache/migration-guide.md` 与 `figma-cache/README.md`。
27
+
28
+ | 变量 | 作用 |
29
+ |------|------|
30
+ | `FIGMA_CACHE_DIR` | 缓存根目录,默认 `figma-cache` |
31
+ | `FIGMA_CACHE_INDEX_FILE` | 索引文件名或绝对路径 |
32
+ | `FIGMA_ITERATIONS_DIR` | `backfill` 扫描的 Markdown 目录;**目录不存在时**仅导致 `backfill` 扫描结果为空,不影响 `validate` |
33
+ | `FIGMA_CACHE_STALE_DAYS` | 陈旧阈值(天) |
34
+ | `FIGMA_DEFAULT_FLOW` | 默认 `flowId` |
35
+ | `FIGMA_CACHE_PROJECT_CONFIG` | 覆盖项目配置文件路径 |
36
+
37
+ ## Cursor 规则与 Skill
38
+
39
+ 将本仓库中的下列文件复制到消费项目(路径保持一致即可):
40
+
41
+ - `.cursor/rules/01-figma-cache-core.mdc`(数据层,建议 always on)
42
+ - `.cursor/rules/02-figma-vuetify2-adapter.mdc`(本仓库示例栈;其他栈请自建 `02-figma-*-adapter.mdc`)
43
+ - `.cursor/rules/figma-local-cache-first.mdc`(入口说明,可选)
44
+ - `.cursor/skills/figma-mcp-local-cache/SKILL.md`
45
+
46
+ 详见 `figma-cache/migration-guide.md`。
47
+
48
+ ## 默认不把业务缓存打进 npm 包
49
+
50
+ `package.json` 的 `files` 白名单仅包含 CLI 与 `figma-cache` 下的脚本及 `*.md` 规范文档;**不包含** `figma-cache/files/` 与 `figma-cache/index.json`。消费方应在自有项目中执行 `figma-cache init` 与 `ensure` 生成数据目录。
51
+
52
+ ## 自用与偶发发版
53
+
54
+ 1. 日常:保持本仓库内 `figma-cache/index.json` 与 `figma-cache/files/` 与你们约定一致,并定期跑 `npm run figma:cache:validate`。
55
+ 2. 发版前:在 `package.json` 中补全 `repository` / `bugs` / `homepage`(若有公开 Git 地址),按 semver 提升 `version`,把变更摘要写入 `CHANGELOG.md` 对应版本。
56
+ 3. `npm pack` 或 `npm publish` 前会执行 **`prepack`**(即 `figma-cache validate`);若故意去掉本地缓存做「纯工具 tarball」,需先保证能通过校验或临时调整流程(默认假设本仓库带可校验的示例/真实索引)。
57
+
58
+ ## 开发自检
59
+
60
+ ```bash
61
+ npm run figma:cache:config
62
+ npm run figma:cache:validate
63
+ npm pack
64
+ npm publish --dry-run
65
+ ```
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require("../figma-cache/figma-cache.js");
@@ -0,0 +1,87 @@
1
+ # Figma Cache
2
+
3
+ 该目录集中管理 Figma 缓存流程(脚本、索引、规范、样例缓存)。
4
+
5
+ ## 使用方式(重要)
6
+
7
+ - 日常只需要把 Figma 链接发给 agent。
8
+ - agent 会自动完成:缓存查询 -> 必要时调用 MCP -> 回写缓存 -> 校验。
9
+ - 你不需要手动执行命令,命令主要用于排障和迁移验证。
10
+
11
+ ## 目录结构
12
+
13
+ - `figma-cache/figma-cache.js`:缓存流程脚本主入口
14
+ - `figma-cache/index.json`:全量索引
15
+ - `figma-cache/files/...`:节点缓存内容
16
+ - `figma-cache/link-normalization-spec.md`:链接标准化规则
17
+ - `figma-cache/validation-checklist.md`:缓存校验清单
18
+ - `figma-cache/backfill-guide.md`:历史回填指南
19
+ - `figma-cache/migration-guide.md`:跨项目移植指南
20
+ - `figma-cache/flow-edge-taxonomy.md`:流程边类型约定
21
+
22
+ ## 默认配置
23
+
24
+ - `FIGMA_CACHE_DIR=figma-cache`
25
+ - `FIGMA_CACHE_INDEX_FILE=index.json`
26
+ - `FIGMA_ITERATIONS_DIR=library/figma-iterations`
27
+ - `FIGMA_CACHE_STALE_DAYS=14`
28
+ - `FIGMA_DEFAULT_FLOW`:默认 flowId;设置后 `flow add-node/link/chain/show/mermaid` 可省略 `--flow=...`
29
+
30
+ 查看当前配置:
31
+
32
+ ```bash
33
+ npm run figma:cache:config
34
+ ```
35
+
36
+ PowerShell 示例(设置默认 flow,减少重复参数):
37
+
38
+ ```powershell
39
+ $env:FIGMA_DEFAULT_FLOW="sip-calling-phase1"
40
+ npm run figma:cache:config
41
+ ```
42
+
43
+ ## 常用命令(通常由 agent 自动执行)
44
+
45
+ - `npm run figma:cache:init`
46
+ - `npm run figma:cache:get -- "<figma-url>"`
47
+ - `npm run figma:cache:ensure -- "<figma-url>" --source=figma-mcp --completeness=layout,text,tokens,interactions`
48
+ - `npm run figma:cache:validate`
49
+ - `npm run figma:cache:stale`
50
+ - `npm run figma:cache:backfill`
51
+
52
+ ## 流程关系(Flow)
53
+
54
+ `index.json` 现在包含 `flows`,用于维护“业务/交互流程”的节点集合与边关系。
55
+
56
+ 常用命令:
57
+
58
+ - `npm run figma:cache:flow:init -- --id=sip-calling-flow --title="SIP Calling"`
59
+ - `npm run figma:cache:flow:add-node -- --flow=sip-calling-flow "<figma-url>"`(要求 `items` 已存在;如需同时创建缓存项可加 `--ensure`)
60
+ - `npm run figma:cache:flow:link -- --flow=sip-calling-flow "<fromUrl>" "<toUrl>" --type=next_step`
61
+ - `npm run figma:cache:flow:chain -- --flow=sip-calling-flow "<url1>" "<url2>" "<url3>" --type=related`
62
+ - `npm run figma:cache:flow:show -- --flow=sip-calling-flow`
63
+ - `npm run figma:cache:flow:mermaid -- --flow=sip-calling-flow`
64
+
65
+ 说明:
66
+
67
+ - `items` 仍然是单节点缓存索引。
68
+ - `flows` 负责把多个 `cacheKey` 组织成流程图,并记录边类型(如 `next_step/branch/related`)。
69
+
70
+ ## 大迭代工作流(推荐)
71
+
72
+ 1. `flow init` 固定一个 `flowId`(整个迭代只用这一个,或配合 `FIGMA_DEFAULT_FLOW`)
73
+ 2. 每个新节点:先 `ensure` 写入 `items`,再 `flow add-node` 挂到 flow(必要时 `--ensure`)
74
+ 3. 当你描述跳转/下一步/分支:用 `flow link`;批量默认串联可用 `flow chain`
75
+ 4. 定期 `validate`;需要看图用 `flow mermaid`
76
+
77
+ 边类型约定见:`figma-cache/flow-edge-taxonomy.md`
78
+
79
+ ## 纯净版初始化
80
+
81
+ - 如果你准备移植“纯净版”(删除 `index.json` 和 `files/`),可先执行:
82
+
83
+ ```bash
84
+ npm run figma:cache:init
85
+ ```
86
+
87
+ - 该命令只创建空索引,不会创建任何节点缓存文件。
@@ -0,0 +1,18 @@
1
+ # Figma 历史数据回填指南
2
+
3
+ 目标:扫描历史文档中的 Figma 链接并补入缓存索引。
4
+
5
+ ## 执行
6
+
7
+ ```bash
8
+ npm run figma:cache:backfill
9
+ ```
10
+
11
+ 默认扫描目录由 `FIGMA_ITERATIONS_DIR` 决定,默认值是 `library/figma-iterations`。
12
+
13
+ ## 回填后检查
14
+
15
+ ```bash
16
+ npm run figma:cache:validate
17
+ npm run figma:cache:stale
18
+ ```
@@ -0,0 +1,918 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable no-console */
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { createRequire } = require("module");
6
+ const { URL } = require("url");
7
+
8
+ const ROOT = process.cwd();
9
+ const NORMALIZATION_VERSION = 1;
10
+ const SCHEMA_VERSION = 2;
11
+
12
+ function parsePositiveInt(input, fallback) {
13
+ const n = Number(input);
14
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
15
+ }
16
+
17
+ function normalizeSlash(input) {
18
+ return input.replace(/\\/g, "/");
19
+ }
20
+
21
+ function resolveMaybeAbsolutePath(input) {
22
+ if (path.isAbsolute(input)) {
23
+ return path.normalize(input);
24
+ }
25
+ return path.join(ROOT, input);
26
+ }
27
+
28
+ function toProjectRelativeOrAbsolute(absPath) {
29
+ const relative = path.relative(ROOT, absPath);
30
+ if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
31
+ return normalizeSlash(relative);
32
+ }
33
+ return normalizeSlash(absPath);
34
+ }
35
+
36
+ const CACHE_DIR_INPUT = process.env.FIGMA_CACHE_DIR || "figma-cache";
37
+ const ITERATIONS_DIR_INPUT =
38
+ process.env.FIGMA_ITERATIONS_DIR || "library/figma-iterations";
39
+ const INDEX_FILE_NAME = process.env.FIGMA_CACHE_INDEX_FILE || "index.json";
40
+ const DEFAULT_FLOW_ID = process.env.FIGMA_DEFAULT_FLOW || "";
41
+ const DEFAULT_STALE_DAYS = parsePositiveInt(
42
+ process.env.FIGMA_CACHE_STALE_DAYS,
43
+ 14
44
+ );
45
+
46
+ const CACHE_DIR = resolveMaybeAbsolutePath(CACHE_DIR_INPUT);
47
+ const ITERATIONS_DIR = resolveMaybeAbsolutePath(ITERATIONS_DIR_INPUT);
48
+ const INDEX_PATH = path.isAbsolute(INDEX_FILE_NAME)
49
+ ? INDEX_FILE_NAME
50
+ : path.join(CACHE_DIR, INDEX_FILE_NAME);
51
+ const CACHE_BASE_FOR_STORAGE = toProjectRelativeOrAbsolute(CACHE_DIR);
52
+
53
+ /** @type {object | null} null = not loaded yet; after load always an object (possibly empty) */
54
+ let memoProjectConfig = null;
55
+ /** @type {string | null} */
56
+ let memoProjectConfigPath = null;
57
+
58
+ /**
59
+ * Load optional project-level config from (first match):
60
+ * 1) FIGMA_CACHE_PROJECT_CONFIG (absolute or relative to ROOT)
61
+ * 2) figma-cache.config.js
62
+ * 3) .figmacacherc.js
63
+ * Core stays agnostic: only reads `hooks` and other neutral keys.
64
+ */
65
+ function loadProjectConfig() {
66
+ if (memoProjectConfig) {
67
+ return memoProjectConfig;
68
+ }
69
+ const candidates = [];
70
+ if (process.env.FIGMA_CACHE_PROJECT_CONFIG) {
71
+ candidates.push(resolveMaybeAbsolutePath(process.env.FIGMA_CACHE_PROJECT_CONFIG));
72
+ }
73
+ candidates.push(path.join(ROOT, "figma-cache.config.js"));
74
+ candidates.push(path.join(ROOT, ".figmacacherc.js"));
75
+
76
+ const requireFromRoot = createRequire(path.join(ROOT, "package.json"));
77
+
78
+ for (const absPath of candidates) {
79
+ if (!fs.existsSync(absPath)) {
80
+ continue;
81
+ }
82
+ try {
83
+ const mod = requireFromRoot(absPath);
84
+ const cfg = mod && mod.default ? mod.default : mod;
85
+ memoProjectConfig = cfg && typeof cfg === "object" ? cfg : {};
86
+ memoProjectConfigPath = absPath;
87
+ return memoProjectConfig;
88
+ } catch (err) {
89
+ console.error(`[figma-cache] project config failed (${absPath}): ${err.message}`);
90
+ }
91
+ }
92
+
93
+ memoProjectConfig = {};
94
+ memoProjectConfigPath = null;
95
+ return memoProjectConfig;
96
+ }
97
+
98
+ /**
99
+ * After generic entry files exist, run optional hooks.postEnsure from project config.
100
+ * Never throws; never changes process exit code of the caller.
101
+ * @param {string} cacheKey
102
+ * @param {object} item index item
103
+ */
104
+ function runPostEnsureHook(cacheKey, item) {
105
+ if (!item || !item.paths) {
106
+ return;
107
+ }
108
+ const cfg = loadProjectConfig();
109
+ const hooks = cfg && cfg.hooks;
110
+ if (!hooks || typeof hooks.postEnsure !== "function") {
111
+ return;
112
+ }
113
+ const ctx = {
114
+ cacheKey,
115
+ fileKey: item.fileKey,
116
+ nodeId: item.nodeId == null ? null : item.nodeId,
117
+ scope: item.scope,
118
+ paths: {
119
+ raw: item.paths.raw,
120
+ spec: item.paths.spec,
121
+ meta: item.paths.meta,
122
+ stateMap: item.paths.stateMap,
123
+ },
124
+ root: ROOT,
125
+ };
126
+ try {
127
+ hooks.postEnsure(ctx);
128
+ } catch (err) {
129
+ console.error(`[figma-cache] hooks.postEnsure: ${err.message}`);
130
+ }
131
+ }
132
+
133
+ function resolveFlowIdFromArgs(rest) {
134
+ const flowArg = rest.find((x) => x.startsWith("--flow="));
135
+ if (flowArg) {
136
+ return flowArg.split("=")[1];
137
+ }
138
+ if (DEFAULT_FLOW_ID) {
139
+ return DEFAULT_FLOW_ID;
140
+ }
141
+ return "";
142
+ }
143
+
144
+ function ensureCacheDir() {
145
+ if (!fs.existsSync(CACHE_DIR)) {
146
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
147
+ }
148
+ }
149
+
150
+ function readIndex() {
151
+ ensureCacheDir();
152
+ if (!fs.existsSync(INDEX_PATH)) {
153
+ return buildEmptyIndex();
154
+ }
155
+ const raw = fs.readFileSync(INDEX_PATH, "utf8");
156
+ return normalizeIndexShape(JSON.parse(raw));
157
+ }
158
+
159
+ function buildEmptyIndex() {
160
+ return {
161
+ schemaVersion: SCHEMA_VERSION,
162
+ version: 1,
163
+ normalizationVersion: NORMALIZATION_VERSION,
164
+ updatedAt: null,
165
+ flows: {},
166
+ items: {},
167
+ };
168
+ }
169
+
170
+ function normalizeIndexShape(index) {
171
+ if (!index || typeof index !== "object") {
172
+ return buildEmptyIndex();
173
+ }
174
+ if (!index.schemaVersion) {
175
+ index.schemaVersion = SCHEMA_VERSION;
176
+ }
177
+ if (!index.flows || typeof index.flows !== "object") {
178
+ index.flows = {};
179
+ }
180
+ if (!index.items || typeof index.items !== "object") {
181
+ index.items = {};
182
+ }
183
+ if (!index.version) {
184
+ index.version = 1;
185
+ }
186
+ if (!index.normalizationVersion) {
187
+ index.normalizationVersion = NORMALIZATION_VERSION;
188
+ }
189
+ return index;
190
+ }
191
+
192
+ function writeIndex(index) {
193
+ const normalized = normalizeIndexShape(index);
194
+ normalized.version = 1;
195
+ normalized.schemaVersion = SCHEMA_VERSION;
196
+ normalized.normalizationVersion = NORMALIZATION_VERSION;
197
+ normalized.updatedAt = new Date().toISOString();
198
+ fs.writeFileSync(INDEX_PATH, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
199
+ }
200
+
201
+ function sanitizeNodeId(nodeId) {
202
+ return String(nodeId).replace(/:/g, "-");
203
+ }
204
+
205
+ function normalizeNodeIdValue(nodeId) {
206
+ const raw = String(nodeId).trim();
207
+ const dashPattern = /^(\d+)-(\d+)$/;
208
+ if (dashPattern.test(raw)) {
209
+ return raw.replace(dashPattern, "$1:$2");
210
+ }
211
+ return raw;
212
+ }
213
+
214
+ function normalizeFigmaUrl(inputUrl) {
215
+ let parsed;
216
+ try {
217
+ parsed = new URL(inputUrl);
218
+ } catch (error) {
219
+ throw new Error(`非法 URL: ${inputUrl}`);
220
+ }
221
+
222
+ const hostOk = /(^|\.)figma\.com$/i.test(parsed.hostname);
223
+ if (!hostOk) {
224
+ throw new Error(`非 Figma 域名: ${parsed.hostname}`);
225
+ }
226
+
227
+ const parts = parsed.pathname.split("/").filter(Boolean);
228
+ const routeType = parts[0];
229
+ const fileKey = parts[1];
230
+ if (!["file", "design"].includes(routeType) || !fileKey) {
231
+ throw new Error(`无法从路径提取 fileKey: ${parsed.pathname}`);
232
+ }
233
+
234
+ const nodeIdRaw = parsed.searchParams.get("node-id");
235
+ const nodeId = nodeIdRaw ? normalizeNodeIdValue(decodeURIComponent(nodeIdRaw)) : null;
236
+ const isNodeScope = !!nodeId;
237
+ const scope = isNodeScope ? "node" : "file";
238
+ const cacheKey = isNodeScope ? `${fileKey}#${nodeId}` : `${fileKey}#__FILE__`;
239
+
240
+ return {
241
+ fileKey,
242
+ nodeId,
243
+ scope,
244
+ cacheKey,
245
+ normalizedUrl: isNodeScope
246
+ ? `https://www.figma.com/file/${fileKey}/?node-id=${encodeURIComponent(nodeId)}`
247
+ : `https://www.figma.com/file/${fileKey}/`,
248
+ originalUrl: inputUrl,
249
+ normalizationVersion: NORMALIZATION_VERSION,
250
+ };
251
+ }
252
+
253
+ function buildPaths(normalized) {
254
+ if (normalized.scope === "file") {
255
+ return {
256
+ meta: `${CACHE_BASE_FOR_STORAGE}/files/${normalized.fileKey}/meta.json`,
257
+ spec: `${CACHE_BASE_FOR_STORAGE}/files/${normalized.fileKey}/spec.md`,
258
+ stateMap: `${CACHE_BASE_FOR_STORAGE}/files/${normalized.fileKey}/state-map.md`,
259
+ raw: `${CACHE_BASE_FOR_STORAGE}/files/${normalized.fileKey}/raw.json`,
260
+ };
261
+ }
262
+
263
+ const safeNode = sanitizeNodeId(normalized.nodeId);
264
+ return {
265
+ meta: `${CACHE_BASE_FOR_STORAGE}/files/${normalized.fileKey}/nodes/${safeNode}/meta.json`,
266
+ spec: `${CACHE_BASE_FOR_STORAGE}/files/${normalized.fileKey}/nodes/${safeNode}/spec.md`,
267
+ stateMap: `${CACHE_BASE_FOR_STORAGE}/files/${normalized.fileKey}/nodes/${safeNode}/state-map.md`,
268
+ raw: `${CACHE_BASE_FOR_STORAGE}/files/${normalized.fileKey}/nodes/${safeNode}/raw.json`,
269
+ };
270
+ }
271
+
272
+ function getItem(index, cacheKey) {
273
+ const normalized = normalizeIndexShape(index);
274
+ return normalized.items && normalized.items[cacheKey] ? normalized.items[cacheKey] : null;
275
+ }
276
+
277
+ function upsertByUrl(inputUrl, extra) {
278
+ const normalized = normalizeFigmaUrl(inputUrl);
279
+ const index = normalizeIndexShape(readIndex());
280
+ const oldItem = getItem(index, normalized.cacheKey);
281
+ const mergedUrls = Array.from(
282
+ new Set([...(oldItem ? oldItem.originalUrls || [] : []), normalized.originalUrl])
283
+ );
284
+ const now = new Date().toISOString();
285
+
286
+ const item = {
287
+ fileKey: normalized.fileKey,
288
+ nodeId: normalized.nodeId,
289
+ scope: normalized.scope,
290
+ url: normalized.normalizedUrl,
291
+ originalUrls: mergedUrls,
292
+ normalizationVersion: NORMALIZATION_VERSION,
293
+ paths: oldItem && oldItem.paths ? oldItem.paths : buildPaths(normalized),
294
+ syncedAt: now,
295
+ completeness: Array.isArray(extra.completeness)
296
+ ? extra.completeness
297
+ : oldItem && Array.isArray(oldItem.completeness)
298
+ ? oldItem.completeness
299
+ : [],
300
+ source: extra.source || (oldItem && oldItem.source) || "manual",
301
+ };
302
+
303
+ index.items = index.items || {};
304
+ index.items[normalized.cacheKey] = item;
305
+ writeIndex(index);
306
+ return { normalized, item };
307
+ }
308
+
309
+ function ensureFileWithDefault(relativePath, fallbackContent) {
310
+ const absPath = resolveMaybeAbsolutePath(relativePath);
311
+ const dir = path.dirname(absPath);
312
+ if (!fs.existsSync(dir)) {
313
+ fs.mkdirSync(dir, { recursive: true });
314
+ }
315
+ if (!fs.existsSync(absPath)) {
316
+ fs.writeFileSync(absPath, fallbackContent, "utf8");
317
+ }
318
+ }
319
+
320
+ function ensureEntryFiles(item) {
321
+ ensureFileWithDefault(
322
+ item.paths.meta,
323
+ `${JSON.stringify(
324
+ {
325
+ fileKey: item.fileKey,
326
+ nodeId: item.nodeId,
327
+ scope: item.scope,
328
+ source: item.source,
329
+ syncedAt: item.syncedAt,
330
+ },
331
+ null,
332
+ 2
333
+ )}\n`
334
+ );
335
+ ensureFileWithDefault(
336
+ item.paths.spec,
337
+ `# Figma Spec\n\n- fileKey: ${item.fileKey}\n- scope: ${item.scope}\n- nodeId: ${item.nodeId || "N/A"}\n- source: ${item.source}\n- syncedAt: ${item.syncedAt}\n`
338
+ );
339
+ ensureFileWithDefault(
340
+ item.paths.stateMap,
341
+ `# State Map\n\n- TODO: 补充状态与交互映射。\n`
342
+ );
343
+ ensureFileWithDefault(item.paths.raw, "{}\n");
344
+ }
345
+
346
+ /**
347
+ * Writes generic cache skeleton files, then invokes optional lifecycle hook.
348
+ * @param {string} cacheKey
349
+ * @param {object} item
350
+ */
351
+ function ensureEntryFilesAndHook(cacheKey, item) {
352
+ ensureEntryFiles(item);
353
+ runPostEnsureHook(cacheKey, item);
354
+ }
355
+
356
+ function validateIndex(index) {
357
+ const errors = [];
358
+ const normalized = normalizeIndexShape(index);
359
+ const keys = Object.keys(normalized.items || {});
360
+ keys.forEach((cacheKey) => {
361
+ const item = normalized.items[cacheKey];
362
+ const required = [
363
+ "fileKey",
364
+ "scope",
365
+ "url",
366
+ "originalUrls",
367
+ "normalizationVersion",
368
+ "paths",
369
+ "syncedAt",
370
+ "completeness",
371
+ ];
372
+ required.forEach((field) => {
373
+ if (item[field] === undefined || item[field] === null) {
374
+ errors.push(`${cacheKey}: 缺少字段 ${field}`);
375
+ }
376
+ });
377
+ if (item.scope === "node" && !item.nodeId) {
378
+ errors.push(`${cacheKey}: node 作用域必须包含 nodeId`);
379
+ }
380
+ });
381
+
382
+ const flowKeys = Object.keys(normalized.flows || {});
383
+ flowKeys.forEach((flowId) => {
384
+ const flow = normalized.flows[flowId];
385
+ if (!flow || typeof flow !== "object") {
386
+ errors.push(`flow ${flowId}: 非法结构`);
387
+ return;
388
+ }
389
+ if (!flow.id || flow.id !== flowId) {
390
+ errors.push(`flow ${flowId}: id 字段缺失或不一致`);
391
+ }
392
+ if (!Array.isArray(flow.nodes)) {
393
+ errors.push(`flow ${flowId}: nodes 必须是数组`);
394
+ }
395
+ if (!Array.isArray(flow.edges)) {
396
+ errors.push(`flow ${flowId}: edges 必须是数组`);
397
+ }
398
+ if (Array.isArray(flow.edges)) {
399
+ flow.edges.forEach((edge, idx) => {
400
+ if (!edge || typeof edge !== "object") {
401
+ errors.push(`flow ${flowId}: edge[${idx}] 非法`);
402
+ return;
403
+ }
404
+ if (!edge.from || !edge.to) {
405
+ errors.push(`flow ${flowId}: edge[${idx}] 缺少 from/to`);
406
+ }
407
+ if (!edge.type) {
408
+ errors.push(`flow ${flowId}: edge[${idx}] 缺少 type`);
409
+ }
410
+ if (edge.from && !normalized.items[edge.from]) {
411
+ errors.push(`flow ${flowId}: edge[${idx}] from 不存在于 items: ${edge.from}`);
412
+ }
413
+ if (edge.to && !normalized.items[edge.to]) {
414
+ errors.push(`flow ${flowId}: edge[${idx}] to 不存在于 items: ${edge.to}`);
415
+ }
416
+ });
417
+ }
418
+ if (Array.isArray(flow.nodes)) {
419
+ flow.nodes.forEach((cacheKey) => {
420
+ if (!normalized.items[cacheKey]) {
421
+ errors.push(`flow ${flowId}: nodes 引用不存在于 items: ${cacheKey}`);
422
+ }
423
+ });
424
+ }
425
+ });
426
+
427
+ return errors;
428
+ }
429
+
430
+ function collectMarkdownFiles(dir) {
431
+ if (!fs.existsSync(dir)) {
432
+ return [];
433
+ }
434
+ const output = [];
435
+ const list = fs.readdirSync(dir, { withFileTypes: true });
436
+ list.forEach((entry) => {
437
+ const fullPath = path.join(dir, entry.name);
438
+ if (entry.isDirectory()) {
439
+ output.push(...collectMarkdownFiles(fullPath));
440
+ return;
441
+ }
442
+ if (entry.isFile() && entry.name.endsWith(".md")) {
443
+ output.push(fullPath);
444
+ }
445
+ });
446
+ return output;
447
+ }
448
+
449
+ function extractFigmaUrls(content) {
450
+ const pattern = /https:\/\/www\.figma\.com\/(?:file|design)\/[^\s)\]]+/g;
451
+ return content.match(pattern) || [];
452
+ }
453
+
454
+ function backfillFromIterations() {
455
+ const files = collectMarkdownFiles(ITERATIONS_DIR);
456
+ let hit = 0;
457
+ files.forEach((filePath) => {
458
+ const content = fs.readFileSync(filePath, "utf8");
459
+ const urls = extractFigmaUrls(content);
460
+ urls.forEach((url) => {
461
+ try {
462
+ upsertByUrl(url, { source: "backfill" });
463
+ hit += 1;
464
+ } catch (error) {
465
+ // 忽略无法解析的 URL
466
+ }
467
+ });
468
+ });
469
+ console.log(`Backfill done. scanned files=${files.length}, urls=${hit}`);
470
+ }
471
+
472
+ function slugifyFlowId(name) {
473
+ const raw = String(name || "")
474
+ .trim()
475
+ .toLowerCase()
476
+ .replace(/[^a-z0-9]+/g, "-")
477
+ .replace(/^-+|-+$/g, "");
478
+ return raw || `flow-${Date.now()}`;
479
+ }
480
+
481
+ function ensureFlow(index, flowId, meta) {
482
+ const normalized = normalizeIndexShape(index);
483
+ normalized.flows = normalized.flows || {};
484
+ if (!normalized.flows[flowId]) {
485
+ normalized.flows[flowId] = {
486
+ id: flowId,
487
+ title: meta && meta.title ? meta.title : flowId,
488
+ description: meta && meta.description ? meta.description : "",
489
+ createdAt: new Date().toISOString(),
490
+ updatedAt: new Date().toISOString(),
491
+ nodes: [],
492
+ edges: [],
493
+ assumptions: [],
494
+ openQuestions: [],
495
+ };
496
+ }
497
+ return normalized.flows[flowId];
498
+ }
499
+
500
+ function upsertFlowNode(index, flowId, cacheKey) {
501
+ const flow = ensureFlow(index, flowId, {});
502
+ if (!flow.nodes.includes(cacheKey)) {
503
+ flow.nodes.push(cacheKey);
504
+ }
505
+ flow.updatedAt = new Date().toISOString();
506
+ }
507
+
508
+ function addFlowEdge(index, flowId, fromKey, toKey, type, note) {
509
+ const flow = ensureFlow(index, flowId, {});
510
+ const edge = {
511
+ id: `${fromKey}->${toKey}:${type}:${Date.now()}`,
512
+ from: fromKey,
513
+ to: toKey,
514
+ type,
515
+ note: note || "",
516
+ createdAt: new Date().toISOString(),
517
+ };
518
+ flow.edges.push(edge);
519
+ flow.updatedAt = new Date().toISOString();
520
+ }
521
+
522
+ /** @returns {string} */
523
+ function inferCliExample() {
524
+ const n = normalizeSlash(String(process.argv[1] || ""));
525
+ if (/\/bin\/figma-cache\.js$/i.test(n)) {
526
+ return "node bin/figma-cache.js";
527
+ }
528
+ if (/\/figma-cache\/figma-cache\.js$/i.test(n)) {
529
+ return "node figma-cache/figma-cache.js";
530
+ }
531
+ return "figma-cache";
532
+ }
533
+
534
+ function printStale(days) {
535
+ const index = readIndex();
536
+ const now = Date.now();
537
+ const threshold = days * 24 * 60 * 60 * 1000;
538
+ const keys = Object.keys(index.items || {});
539
+ const stale = keys.filter((cacheKey) => {
540
+ const item = index.items[cacheKey];
541
+ const ts = item.syncedAt ? Date.parse(item.syncedAt) : NaN;
542
+ if (Number.isNaN(ts)) {
543
+ return true;
544
+ }
545
+ return now - ts > threshold;
546
+ });
547
+ if (!stale.length) {
548
+ console.log(`No stale entries (>${days}d).`);
549
+ return;
550
+ }
551
+ console.log(`Stale entries (>${days}d):`);
552
+ stale.forEach((key) => {
553
+ console.log(`- ${key}`);
554
+ });
555
+ }
556
+
557
+ function run() {
558
+ const [, , cmd, ...args] = process.argv;
559
+ if (!cmd) {
560
+ const ex = inferCliExample();
561
+ console.log("Usage:");
562
+ console.log(` (invoke examples: ${ex} | node bin/figma-cache.js | node figma-cache/figma-cache.js)`);
563
+ console.log(` ${ex} normalize <figmaUrl>`);
564
+ console.log(` ${ex} get <figmaUrl>`);
565
+ console.log(` ${ex} upsert <figmaUrl> [--source=manual] [--completeness=a,b]`);
566
+ console.log(` ${ex} validate`);
567
+ console.log(` ${ex} stale [--days=14]`);
568
+ console.log(` ${ex} backfill`);
569
+ console.log(` ${ex} ensure <figmaUrl> [--source=manual] [--completeness=a,b]`);
570
+ console.log(` ${ex} init`);
571
+ console.log(` ${ex} config`);
572
+ console.log(
573
+ " (optional) figma-cache.config.js | .figmacacherc.js | FIGMA_CACHE_PROJECT_CONFIG — hooks.postEnsure after ensure"
574
+ );
575
+ console.log(` ${ex} flow init --id=<flowId> [--title=...]`);
576
+ console.log(
577
+ `${ex} flow add-node --flow=<flowId> <figmaUrl> [--ensure] [--source=manual] [--completeness=a,b]`
578
+ );
579
+ console.log(
580
+ `${ex} flow link --flow=<flowId> <fromUrl> <toUrl> --type=next_step [--note=...]`
581
+ );
582
+ console.log(
583
+ `${ex} flow chain --flow=<flowId> <url1> <url2> ... [--type=next_step|related]`
584
+ );
585
+ console.log(` ${ex} flow show --flow=<flowId>`);
586
+ console.log(` ${ex} flow mermaid --flow=<flowId>`);
587
+ process.exit(1);
588
+ }
589
+
590
+ if (cmd === "normalize") {
591
+ const url = args[0];
592
+ const normalized = normalizeFigmaUrl(url);
593
+ console.log(JSON.stringify(normalized, null, 2));
594
+ return;
595
+ }
596
+
597
+ if (cmd === "get") {
598
+ const url = args[0];
599
+ const normalized = normalizeFigmaUrl(url);
600
+ const index = readIndex();
601
+ const item = getItem(index, normalized.cacheKey);
602
+ console.log(
603
+ JSON.stringify(
604
+ {
605
+ found: !!item,
606
+ cacheKey: normalized.cacheKey,
607
+ item: item || null,
608
+ },
609
+ null,
610
+ 2
611
+ )
612
+ );
613
+ return;
614
+ }
615
+
616
+ if (cmd === "upsert") {
617
+ const url = args[0];
618
+ const sourceArg = args.find((x) => x.startsWith("--source="));
619
+ const completenessArg = args.find((x) => x.startsWith("--completeness="));
620
+ const source = sourceArg ? sourceArg.split("=")[1] : "manual";
621
+ const completeness = completenessArg
622
+ ? completenessArg
623
+ .split("=")[1]
624
+ .split(",")
625
+ .map((x) => x.trim())
626
+ .filter(Boolean)
627
+ : [];
628
+ const result = upsertByUrl(url, { source, completeness });
629
+ console.log(
630
+ JSON.stringify(
631
+ { cacheKey: result.normalized.cacheKey, scope: result.item.scope, syncedAt: result.item.syncedAt },
632
+ null,
633
+ 2
634
+ )
635
+ );
636
+ return;
637
+ }
638
+
639
+ if (cmd === "ensure") {
640
+ const url = args[0];
641
+ const sourceArg = args.find((x) => x.startsWith("--source="));
642
+ const completenessArg = args.find((x) => x.startsWith("--completeness="));
643
+ const source = sourceArg ? sourceArg.split("=")[1] : "manual";
644
+ const completeness = completenessArg
645
+ ? completenessArg
646
+ .split("=")[1]
647
+ .split(",")
648
+ .map((x) => x.trim())
649
+ .filter(Boolean)
650
+ : [];
651
+ const result = upsertByUrl(url, { source, completeness });
652
+ ensureEntryFilesAndHook(result.normalized.cacheKey, result.item);
653
+ console.log(
654
+ JSON.stringify(
655
+ {
656
+ cacheKey: result.normalized.cacheKey,
657
+ ensured: true,
658
+ paths: result.item.paths,
659
+ },
660
+ null,
661
+ 2
662
+ )
663
+ );
664
+ return;
665
+ }
666
+
667
+ if (cmd === "validate") {
668
+ const index = readIndex();
669
+ const errors = validateIndex(index);
670
+ if (!errors.length) {
671
+ console.log("Validation passed.");
672
+ return;
673
+ }
674
+ console.error("Validation failed:");
675
+ errors.forEach((err) => console.error(`- ${err}`));
676
+ process.exit(2);
677
+ }
678
+
679
+ if (cmd === "stale") {
680
+ const daysArg = args.find((x) => x.startsWith("--days="));
681
+ const days = daysArg ? Number(daysArg.split("=")[1]) : DEFAULT_STALE_DAYS;
682
+ printStale(days);
683
+ return;
684
+ }
685
+
686
+ if (cmd === "backfill") {
687
+ backfillFromIterations();
688
+ return;
689
+ }
690
+
691
+ if (cmd === "config") {
692
+ const cfg = loadProjectConfig();
693
+ const hooks = cfg && cfg.hooks;
694
+ console.log(
695
+ JSON.stringify(
696
+ {
697
+ root: normalizeSlash(ROOT),
698
+ cacheDir: normalizeSlash(CACHE_DIR),
699
+ indexPath: normalizeSlash(INDEX_PATH),
700
+ iterationsDir: normalizeSlash(ITERATIONS_DIR),
701
+ staleDays: DEFAULT_STALE_DAYS,
702
+ defaultFlowId: DEFAULT_FLOW_ID || null,
703
+ normalizationVersion: NORMALIZATION_VERSION,
704
+ projectConfigPath: memoProjectConfigPath
705
+ ? normalizeSlash(memoProjectConfigPath)
706
+ : null,
707
+ hooks: {
708
+ postEnsure: !!(hooks && typeof hooks.postEnsure === "function"),
709
+ },
710
+ },
711
+ null,
712
+ 2
713
+ )
714
+ );
715
+ return;
716
+ }
717
+
718
+ if (cmd === "init") {
719
+ ensureCacheDir();
720
+ if (fs.existsSync(INDEX_PATH)) {
721
+ console.log(
722
+ JSON.stringify(
723
+ {
724
+ created: false,
725
+ reason: "index_exists",
726
+ indexPath: normalizeSlash(INDEX_PATH),
727
+ },
728
+ null,
729
+ 2
730
+ )
731
+ );
732
+ return;
733
+ }
734
+ writeIndex(buildEmptyIndex());
735
+ console.log(
736
+ JSON.stringify(
737
+ {
738
+ created: true,
739
+ indexPath: normalizeSlash(INDEX_PATH),
740
+ },
741
+ null,
742
+ 2
743
+ )
744
+ );
745
+ return;
746
+ }
747
+
748
+ if (cmd === "flow") {
749
+ const sub = args[0];
750
+ const rest = args.slice(1);
751
+ if (!sub) {
752
+ console.error("Missing flow subcommand");
753
+ process.exit(1);
754
+ }
755
+
756
+ if (sub === "init") {
757
+ const idArg = rest.find((x) => x.startsWith("--id="));
758
+ const titleArg = rest.find((x) => x.startsWith("--title="));
759
+ const descArg = rest.find((x) => x.startsWith("--description="));
760
+ const flowId = idArg ? idArg.split("=")[1] : slugifyFlowId("flow");
761
+ const title = titleArg ? titleArg.split("=").slice(1).join("=") : flowId;
762
+ const description = descArg ? descArg.split("=").slice(1).join("=") : "";
763
+ const index = normalizeIndexShape(readIndex());
764
+ ensureFlow(index, flowId, { title, description });
765
+ writeIndex(index);
766
+ console.log(JSON.stringify({ flowId, created: true }, null, 2));
767
+ return;
768
+ }
769
+
770
+ if (sub === "add-node") {
771
+ const flowId = resolveFlowIdFromArgs(rest);
772
+ if (!flowId) {
773
+ console.error("Missing --flow=<flowId> or env FIGMA_DEFAULT_FLOW");
774
+ process.exit(1);
775
+ }
776
+ const url = rest.find((x) => !x.startsWith("--"));
777
+ const ensureArg = rest.includes("--ensure");
778
+ const sourceArg = rest.find((x) => x.startsWith("--source="));
779
+ const completenessArg = rest.find((x) => x.startsWith("--completeness="));
780
+ const source = sourceArg ? sourceArg.split("=")[1] : "manual";
781
+ const completeness = completenessArg
782
+ ? completenessArg
783
+ .split("=")[1]
784
+ .split(",")
785
+ .map((x) => x.trim())
786
+ .filter(Boolean)
787
+ : [];
788
+ const index = normalizeIndexShape(readIndex());
789
+ const normalized = normalizeFigmaUrl(url);
790
+ if (!ensureArg && !getItem(index, normalized.cacheKey)) {
791
+ console.error(
792
+ `Missing cache item for ${normalized.cacheKey}. Run figma:cache:ensure first, or pass --ensure.`
793
+ );
794
+ process.exit(2);
795
+ }
796
+ if (ensureArg) {
797
+ upsertByUrl(url, { source, completeness });
798
+ const refreshed = normalizeIndexShape(readIndex());
799
+ const item = getItem(refreshed, normalized.cacheKey);
800
+ if (item) {
801
+ ensureEntryFilesAndHook(normalized.cacheKey, item);
802
+ }
803
+ Object.assign(index, refreshed);
804
+ }
805
+ upsertFlowNode(index, flowId, normalized.cacheKey);
806
+ writeIndex(index);
807
+ console.log(
808
+ JSON.stringify(
809
+ { flowId, cacheKey: normalized.cacheKey, added: true, ensured: ensureArg },
810
+ null,
811
+ 2
812
+ )
813
+ );
814
+ return;
815
+ }
816
+
817
+ if (sub === "link") {
818
+ const flowId = resolveFlowIdFromArgs(rest);
819
+ const typeArg = rest.find((x) => x.startsWith("--type="));
820
+ const noteArg = rest.find((x) => x.startsWith("--note="));
821
+ const urls = rest.filter((x) => !x.startsWith("--"));
822
+ if (!flowId) {
823
+ console.error("Missing --flow=<flowId> or env FIGMA_DEFAULT_FLOW");
824
+ process.exit(1);
825
+ }
826
+ if (urls.length < 2) {
827
+ console.error("Missing <fromUrl> <toUrl>");
828
+ process.exit(1);
829
+ }
830
+ const type = typeArg ? typeArg.split("=")[1] : "related";
831
+ const note = noteArg ? noteArg.split("=").slice(1).join("=") : "";
832
+ const from = normalizeFigmaUrl(urls[0]).cacheKey;
833
+ const to = normalizeFigmaUrl(urls[1]).cacheKey;
834
+ const index = normalizeIndexShape(readIndex());
835
+ if (!getItem(index, from) || !getItem(index, to)) {
836
+ console.error("Missing cache item for from/to. Cache urls first with ensure/upsert.");
837
+ process.exit(2);
838
+ }
839
+ upsertFlowNode(index, flowId, from);
840
+ upsertFlowNode(index, flowId, to);
841
+ addFlowEdge(index, flowId, from, to, type, note);
842
+ writeIndex(index);
843
+ console.log(JSON.stringify({ flowId, from, to, type, linked: true }, null, 2));
844
+ return;
845
+ }
846
+
847
+ if (sub === "chain") {
848
+ const flowId = resolveFlowIdFromArgs(rest);
849
+ const typeArg = rest.find((x) => x.startsWith("--type="));
850
+ const type = typeArg ? typeArg.split("=")[1] : "related";
851
+ const urls = rest.filter((x) => !x.startsWith("--"));
852
+ if (!flowId) {
853
+ console.error("Missing --flow=<flowId> or env FIGMA_DEFAULT_FLOW");
854
+ process.exit(1);
855
+ }
856
+ if (urls.length < 2) {
857
+ console.error("Need at least 2 urls");
858
+ process.exit(1);
859
+ }
860
+ const index = normalizeIndexShape(readIndex());
861
+ const keys = urls.map((u) => normalizeFigmaUrl(u).cacheKey);
862
+ keys.forEach((k) => {
863
+ if (!getItem(index, k)) {
864
+ console.error(`Missing cache item for ${k}. Ensure each url is cached first.`);
865
+ process.exit(2);
866
+ }
867
+ });
868
+ keys.forEach((k) => upsertFlowNode(index, flowId, k));
869
+ for (let i = 0; i < keys.length - 1; i += 1) {
870
+ addFlowEdge(index, flowId, keys[i], keys[i + 1], type, "");
871
+ }
872
+ writeIndex(index);
873
+ console.log(JSON.stringify({ flowId, chained: keys.length - 1, type }, null, 2));
874
+ return;
875
+ }
876
+
877
+ if (sub === "show") {
878
+ const flowId = resolveFlowIdFromArgs(rest);
879
+ if (!flowId) {
880
+ console.error("Missing --flow=<flowId> or env FIGMA_DEFAULT_FLOW");
881
+ process.exit(1);
882
+ }
883
+ const index = normalizeIndexShape(readIndex());
884
+ const flow = index.flows[flowId];
885
+ console.log(JSON.stringify({ flowId, flow: flow || null }, null, 2));
886
+ return;
887
+ }
888
+
889
+ if (sub === "mermaid") {
890
+ const flowId = resolveFlowIdFromArgs(rest);
891
+ if (!flowId) {
892
+ console.error("Missing --flow=<flowId> or env FIGMA_DEFAULT_FLOW");
893
+ process.exit(1);
894
+ }
895
+ const index = normalizeIndexShape(readIndex());
896
+ const flow = index.flows[flowId];
897
+ if (!flow) {
898
+ console.error(`Unknown flow: ${flowId}`);
899
+ process.exit(1);
900
+ }
901
+ const lines = ["flowchart LR"];
902
+ (flow.edges || []).forEach((edge) => {
903
+ const label = edge.type || "edge";
904
+ lines.push(` ${edge.from} -->|${label}| ${edge.to}`);
905
+ });
906
+ console.log(lines.join("\n"));
907
+ return;
908
+ }
909
+
910
+ console.error(`Unknown flow subcommand: ${sub}`);
911
+ process.exit(1);
912
+ }
913
+
914
+ console.error(`Unknown command: ${cmd}`);
915
+ process.exit(1);
916
+ }
917
+
918
+ run();
@@ -0,0 +1,33 @@
1
+ # Flow 边类型约定(单人迭代版)
2
+
3
+ 用于 `flows[].edges[].type` 字段。优先少而精,后续可扩展。
4
+
5
+ ## 主路径
6
+
7
+ - `next_step`:主流程下一步(同模块线性推进)
8
+ - `next_page`:跨页面/跨大区块的下一步(仍属主路径)
9
+
10
+ ## 弱关联
11
+
12
+ - `related`:同迭代相关,但不确定先后或类型(占位,后续可升级)
13
+
14
+ ## 分支
15
+
16
+ - `branch_true`:条件成立分支
17
+ - `branch_false`:条件不成立分支
18
+ - `branch_default`:默认分支
19
+
20
+ ## 结构关系
21
+
22
+ - `child`:UI 结构父子(例如弹层属于某屏)
23
+ - `variant`:同一节点的不同状态变体(A 与 A-error)
24
+
25
+ ## 逆向/回退
26
+
27
+ - `back`:返回上一步
28
+ - `cancel`:取消/关闭导致回到某节点
29
+
30
+ ## 备注
31
+
32
+ - 不确定时先用 `related`,不要硬编 `next_step`。
33
+ - 边上可用 `note` 写人话解释(例如“号码校验失败出现警告条”)。
@@ -0,0 +1,16 @@
1
+ # Figma 链接标准化规范
2
+
3
+ 本规范用于把不同形态的 Figma 分享链接归一为同一个缓存键,避免重复缓存与重复 MCP 拉取。
4
+
5
+ ## 标准键定义
6
+
7
+ - `fileKey`: 从路径提取(支持 `/file/` 与 `/design/`)
8
+ - `nodeId`: 从 `node-id` 提取并归一到冒号格式
9
+ - `cacheKey`: `<fileKey>#<nodeId>`
10
+ - 无 `node-id` 时:`<fileKey>#__FILE__`
11
+
12
+ ## 规则
13
+
14
+ 1. URL 解码并去除首尾空白。
15
+ 2. `9278-30678` 这类值转换为 `9278:30678`。
16
+ 3. 仅 `node-id` 参与定位;`t/page-id/mode/...` 视为无关参数。
@@ -0,0 +1,79 @@
1
+ # Figma 缓存流程移植指南
2
+
3
+ ## 需要复制的文件
4
+
5
+ - `figma-cache/`(**建议**:脚本与规范文档全量复制;**业务缓存** `figma-cache/files/` 与 `figma-cache/index.json` 可按需不带——在新项目执行 `npm run figma:cache:init` 再 `ensure` 重建)
6
+ - (**可选**,与本仓库「npm 包」形态一致时)根目录 `bin/figma-cache.js`:CLI 薄封装;若仅复制 `figma-cache/` 并以 `node figma-cache/figma-cache.js` 调用,可不复制 `bin/`
7
+ - **`figma-cache.config.js`(项目根)**:`postEnsure` 与适配文档模板;无则钩子不执行,Core 仍可用
8
+ - `.cursor/rules/01-figma-cache-core.mdc`(数据层,always on)
9
+ - `.cursor/rules/02-figma-vuetify2-adapter.mdc`(本仓库:Vue2+Vuetify2;**其他栈**请用下文 Agent 模板生成自己的 `02-figma-*-adapter.mdc`)
10
+ - `.cursor/rules/figma-local-cache-first.mdc`(入口说明,可选)
11
+ - `.cursor/skills/figma-mcp-local-cache/SKILL.md`
12
+
13
+ ## package.json scripts
14
+
15
+ **方式 A(复制 `figma-cache/` 进业务仓库、不安装本工具 npm 包)**:用 `node` 直接调用脚本。
16
+
17
+ ```json
18
+ {
19
+ "figma:cache:normalize": "node figma-cache/figma-cache.js normalize",
20
+ "figma:cache:get": "node figma-cache/figma-cache.js get",
21
+ "figma:cache:upsert": "node figma-cache/figma-cache.js upsert",
22
+ "figma:cache:ensure": "node figma-cache/figma-cache.js ensure",
23
+ "figma:cache:validate": "node figma-cache/figma-cache.js validate",
24
+ "figma:cache:stale": "node figma-cache/figma-cache.js stale",
25
+ "figma:cache:backfill": "node figma-cache/figma-cache.js backfill",
26
+ "figma:cache:init": "node figma-cache/figma-cache.js init",
27
+ "figma:cache:config": "node figma-cache/figma-cache.js config",
28
+ "figma:cache:flow:init": "node figma-cache/figma-cache.js flow init",
29
+ "figma:cache:flow:add-node": "node figma-cache/figma-cache.js flow add-node",
30
+ "figma:cache:flow:link": "node figma-cache/figma-cache.js flow link",
31
+ "figma:cache:flow:chain": "node figma-cache/figma-cache.js flow chain",
32
+ "figma:cache:flow:show": "node figma-cache/figma-cache.js flow show",
33
+ "figma:cache:flow:mermaid": "node figma-cache/figma-cache.js flow mermaid"
34
+ }
35
+ ```
36
+
37
+ **方式 B(将本工具作为 devDependency 安装)**:包内 `bin/figma-cache.js` 注册为可执行名 `figma-cache`;消费方 `npm run` 通常可直接写 `figma-cache <子命令>`(会走 `node_modules/.bin`)。**本工具链仓库根目录**在无依赖时部分 npm 版本不会把当前包自身写入 `.bin`,故根 `package.json` 使用 `node bin/figma-cache.js <子命令>` 以保证自检可运行;二者等价。
38
+
39
+ ## 迁移后验证
40
+
41
+ 1. `npm run figma:cache:config`
42
+ 2. `npm run figma:cache:validate`
43
+ 3. 用真实链接执行一次 `get -> ensure`
44
+
45
+ ## 可选环境变量
46
+
47
+ - `FIGMA_CACHE_DIR`:缓存根目录
48
+ - `FIGMA_CACHE_INDEX_FILE`:索引文件路径
49
+ - `FIGMA_ITERATIONS_DIR`:历史文档扫描目录(**仅** `backfill` 使用)。目录不存在时扫描结果为空、`validate` 不受影响;需要回填时可选择:创建该目录并放入历史 `.md`、设置本变量指向已有目录、或从旧库拷贝迭代文档树
50
+ - `FIGMA_CACHE_STALE_DAYS`:陈旧阈值(天)
51
+ - `FIGMA_DEFAULT_FLOW`:默认 flowId(大迭代推荐设置)
52
+ - `FIGMA_CACHE_ADAPTER_DOC`:覆盖 `postEnsure` 写入的适配说明文件名(默认见各项目 `figma-cache.config.js`)
53
+
54
+ ## 用 Agent 生成「其他技术栈」的 Adapter 规则
55
+
56
+ 可以。把下面整段丢给 Cursor(把花括号里的内容换成你的栈),让它在 `.cursor/rules/` 下新建或覆盖 `02-figma-*-adapter.mdc`,并视情况改 `figma-cache.config.js` 里生成的节点侧文件名与模板文案。
57
+
58
+ ```text
59
+ 请为本仓库生成 Cursor 规则文件:`.cursor/rules/02-figma-{框架}-adapter.mdc`(alwaysApply: false)。
60
+
61
+ 约束:
62
+ - 本仓库 UI 栈:{例如 Vue 3 + Element Plus + UnoCSS / 或 React + MUI}。
63
+ - 必须先读 01-figma-cache-core 的边界:Core 只维护 figma-cache 通用文件;本文件只约束「读缓存后如何写业务 UI」。
64
+ - 写明:必读 raw.json、spec.md、state-map.md;禁止在 meta/raw/spec 里写框架专有内容。
65
+ - 与 figma-cache.config.js 的 postEnsure 生成的「节点目录下的 *-mapping.*」如何呼应(文件名建议与栈一致)。
66
+ - 中文撰写,简洁可执行。
67
+ ```
68
+
69
+ 生成后把 **01-figma-cache-core.mdc** 里「Adapter 规则」的示例文件名改成你实际文件名(一处引用即可)。
70
+
71
+ ## 将 Core 抽成独立 npm 包(维护方式概要)
72
+
73
+ 1. **包内容**:只发布「中立」部分:`bin/figma-cache.js`(薄封装,加载同包内 `figma-cache/figma-cache.js`)、各 `*.md` 规范与指南;**不要**把某项目的 `figma-cache.config.js`、`.cursor/rules/02-*`、业务缓存目录 `figma-cache/files/` 打进包(默认用 `files` 白名单约束)。
74
+ 2. **package.json**:`"bin": { "figma-cache": "bin/figma-cache.js" }`(或 `@scope/figma-cache`);`engines` 写明 Node 16+;`files` 白名单避免把样例 `index.json` 与大体积 `files/` 打进包。
75
+ 3. **消费方**:`npm i -D @scope/figma-cache`,scripts 改为 `node node_modules/@scope/figma-cache/cli.js ...` 或直接用 `figma-cache` 命令;项目根保留自己的 `figma-cache.config.js` 与 `.cursor/rules`。
76
+ 4. **版本与变更**:遵循 semver;钩子 `ctx` 字段变更算 **minor** 并写 CHANGELOG;破坏性改 `ctx` 或 CLI 行为算 **major**。
77
+ 5. **本仓库过渡**:可先 `npm link` 本地包验证,再发布;发布后将本仓库 `figma-cache/` 中「脚本与 spec」替换为依赖包,目录名可保留 `figma-cache/` 仅放数据与 index(或整目录改名为 `.cache/figma` 由 env 指定)。
78
+
79
+ 更细的发布 checklist 可在发包前补:LICENSE、仓库 URL、`npm publish --dry-run`、`.npmignore`。
@@ -0,0 +1,15 @@
1
+ # Figma 缓存校验清单
2
+
3
+ - [ ] `index.json` 存在对应 `cacheKey`
4
+ - [ ] 记录包含 `fileKey/scope/url/syncedAt`
5
+ - [ ] `normalizationVersion` 与当前规范一致
6
+ - [ ] `paths.meta` 与 `paths.spec` 已定义
7
+ - [ ] `completeness` 覆盖当前任务字段
8
+ - [ ] `scope=node` 时存在 `nodeId`
9
+ - [ ] 若维护了 `flows`,边的 `from/to` 必须存在于 `items`
10
+
11
+ 快速检查:
12
+
13
+ ```bash
14
+ npm run figma:cache:validate
15
+ ```
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "figma-cache-toolchain",
3
+ "version": "1.0.0",
4
+ "description": "Figma link normalization, local cache index, validation, and Node CLI (framework-agnostic core).",
5
+ "keywords": [
6
+ "figma",
7
+ "cache",
8
+ "cli",
9
+ "design-tokens",
10
+ "mcp"
11
+ ],
12
+ "license": "MIT",
13
+ "engines": {
14
+ "node": ">=16.20.0"
15
+ },
16
+ "bin": {
17
+ "figma-cache": "bin/figma-cache.js"
18
+ },
19
+ "files": [
20
+ "LICENSE",
21
+ "bin",
22
+ "figma-cache/figma-cache.js",
23
+ "figma-cache/*.md"
24
+ ],
25
+ "scripts": {
26
+ "prepack": "node bin/figma-cache.js validate",
27
+ "figma:cache:normalize": "node bin/figma-cache.js normalize",
28
+ "figma:cache:get": "node bin/figma-cache.js get",
29
+ "figma:cache:upsert": "node bin/figma-cache.js upsert",
30
+ "figma:cache:ensure": "node bin/figma-cache.js ensure",
31
+ "figma:cache:validate": "node bin/figma-cache.js validate",
32
+ "figma:cache:stale": "node bin/figma-cache.js stale",
33
+ "figma:cache:backfill": "node bin/figma-cache.js backfill",
34
+ "figma:cache:init": "node bin/figma-cache.js init",
35
+ "figma:cache:config": "node bin/figma-cache.js config",
36
+ "figma:cache:flow:init": "node bin/figma-cache.js flow init",
37
+ "figma:cache:flow:add-node": "node bin/figma-cache.js flow add-node",
38
+ "figma:cache:flow:link": "node bin/figma-cache.js flow link",
39
+ "figma:cache:flow:chain": "node bin/figma-cache.js flow chain",
40
+ "figma:cache:flow:show": "node bin/figma-cache.js flow show",
41
+ "figma:cache:flow:mermaid": "node bin/figma-cache.js flow mermaid"
42
+ },
43
+ "volta": {
44
+ "node": "16.20.2"
45
+ }
46
+ }