@hegemonart/get-design-done 1.30.5 → 1.31.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/.claude-plugin/marketplace.json +6 -3
- package/.claude-plugin/plugin.json +5 -2
- package/CHANGELOG.md +129 -0
- package/README.md +22 -1
- package/SKILL.md +1 -0
- package/agents/design-integration-checker.md +1 -1
- package/agents/design-planner.md +1 -1
- package/agents/gdd-graph-refresh.md +90 -0
- package/bin/gdd-graph +261 -0
- package/connections/connections.md +10 -9
- package/connections/graphify.md +65 -54
- package/package.json +8 -3
- package/reference/capability-gap-stage-gate.md +7 -4
- package/reference/model-tiers.md +2 -2
- package/reference/start-interview.md +1 -1
- package/scripts/detect-stale-refs.cjs +6 -0
- package/scripts/lib/figma-extract/digest.cjs +430 -0
- package/scripts/lib/figma-extract/parse-url.cjs +87 -0
- package/scripts/lib/figma-extract/payload-schema.json +108 -0
- package/scripts/lib/figma-extract/pull.cjs +394 -0
- package/scripts/lib/figma-extract/receiver.cjs +273 -0
- package/scripts/lib/figma-extract/render-md.cjs +143 -0
- package/scripts/lib/figma-extract/styles-resolver.cjs +147 -0
- package/scripts/lib/figma-extract/walk.cjs +100 -0
- package/scripts/lib/graph/atomic-write.mjs +68 -0
- package/scripts/lib/graph/build.mjs +124 -0
- package/scripts/lib/graph/diff.mjs +90 -0
- package/scripts/lib/graph/index.mjs +14 -0
- package/scripts/lib/graph/query.mjs +155 -0
- package/scripts/lib/graph/schema.json +69 -0
- package/scripts/lib/graph/schema.mjs +47 -0
- package/scripts/lib/graph/status.mjs +88 -0
- package/scripts/lib/graph/token-estimate.mjs +27 -0
- package/scripts/lib/graph/upsert.mjs +210 -0
- package/scripts/lib/{gsd-health-mirror → health-mirror}/index.cjs +89 -2
- package/scripts/mcp-servers/gdd-mcp/tools/gdd_health.ts +3 -3
- package/skills/connections/connections-onboarding.md +6 -6
- package/skills/figma-extract/SKILL.md +64 -0
- package/skills/graphify/SKILL.md +11 -10
- package/skills/health/SKILL.md +10 -0
- package/skills/scan/scan-procedure.md +9 -8
- package/agents/gdd-graphify-sync.md +0 -110
- /package/scripts/lib/{gsd-health-mirror → health-mirror}/index.d.cts +0 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Plan 31-02 — productionized from spike 001 digest.mjs (orchestration + token extraction).
|
|
4
|
+
*
|
|
5
|
+
* DIGEST stage of the two-stage pipeline (decision D-01: extract → digest stay
|
|
6
|
+
* separated). This module reads ONLY the raw/ cache that pull.cjs (31-01) wrote;
|
|
7
|
+
* it performs ZERO network calls, so it can re-run against an existing cache
|
|
8
|
+
* without re-pulling (idempotent / off-line).
|
|
9
|
+
*
|
|
10
|
+
* Three-path token assembly (decision D-04):
|
|
11
|
+
* Path A — Variables API body (rawDir/variables.json without the plugin marker)
|
|
12
|
+
* Path B — styles resolver (rawDir/styles.json) — pluggable seam; 31-03 ships
|
|
13
|
+
* the real two-step /styles + /nodes?ids= resolver
|
|
14
|
+
* Path C — plugin sync (rawDir/variables.json WITH the receiver marker, written
|
|
15
|
+
* by 31-06's localhost receiver)
|
|
16
|
+
* Resolution priority on name collision: Variables > plugin sync > styles.
|
|
17
|
+
* The --prefer-styles escape inverts the chain to prefer styles.
|
|
18
|
+
*
|
|
19
|
+
* Pure CommonJS, no external deps, no network.
|
|
20
|
+
*
|
|
21
|
+
* Per-component slicing (decision D-08):
|
|
22
|
+
* digest({..., component}) — when `component` is a name or glob (e.g.
|
|
23
|
+
* 'Sample/Button', 'Sample/*', 'Form/Butt?'), the digest renders ONLY
|
|
24
|
+
* the matching component(s) — a ~500-token slice instead of the full
|
|
25
|
+
* ~16K digest. The filter is strictly ADDITIVE: omitting `component`
|
|
26
|
+
* reproduces 31-02's full-digest behavior unchanged. Glob support is a
|
|
27
|
+
* few lines of in-file string work (no external glob dependency).
|
|
28
|
+
*
|
|
29
|
+
* Exports:
|
|
30
|
+
* digest(opts) — async orchestrator (reads raw/, writes digest/)
|
|
31
|
+
* assembleTokens(opts) — pure three-path merge by priority
|
|
32
|
+
* DEFAULT_TOKEN_PRIORITY — ['variables','plugin','styles'] (D-04)
|
|
33
|
+
* globToRegExp(pattern) — minimal glob→RegExp (D-08 component filter)
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const fs = require('node:fs/promises');
|
|
37
|
+
const path = require('node:path');
|
|
38
|
+
const { collectComponents } = require('./walk.cjs');
|
|
39
|
+
const { renderDesignMd } = require('./render-md.cjs');
|
|
40
|
+
|
|
41
|
+
// D-04: Variables > plugin sync > styles.
|
|
42
|
+
const DEFAULT_TOKEN_PRIORITY = ['variables', 'plugin', 'styles'];
|
|
43
|
+
|
|
44
|
+
// Receiver-written payload marker (31-06 contract). A rawDir/variables.json that
|
|
45
|
+
// carries this top-level field is the plugin's Path-C payload, NOT the Figma
|
|
46
|
+
// Variables API body.
|
|
47
|
+
const PLUGIN_PAYLOAD_MARKER = 'gdd-plugin';
|
|
48
|
+
|
|
49
|
+
// ── component filter (D-08) ────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Translate a minimal glob pattern into an anchored, case-sensitive RegExp.
|
|
53
|
+
*
|
|
54
|
+
* Supported wildcards (the only two the acceptance criterion needs):
|
|
55
|
+
* `*` → `.*` (zero or more of any char)
|
|
56
|
+
* `?` → `.` (exactly one char)
|
|
57
|
+
* Every other character — including regex metacharacters like `.`/`/`/`(`/`+`
|
|
58
|
+
* and the Figma `Sample/Path/Name` separators — is treated LITERALLY. We escape
|
|
59
|
+
* the whole pattern first, then re-activate the escaped `\*`/`\?` placeholders,
|
|
60
|
+
* so e.g. the `.` in 'Sample.Icon' is literal and does NOT over-match 'Sample/Icon'.
|
|
61
|
+
*
|
|
62
|
+
* Matching is exact (anchored `^...$`) and case-sensitive — Figma component
|
|
63
|
+
* names are case-significant, and an exact name with no wildcard must match only
|
|
64
|
+
* that one component.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} pattern a component name or glob (e.g. 'Button*', 'Form/*')
|
|
67
|
+
* @returns {RegExp}
|
|
68
|
+
*/
|
|
69
|
+
function globToRegExp(pattern) {
|
|
70
|
+
// Escape ALL regex metacharacters (incl. * and ? for now).
|
|
71
|
+
const escaped = String(pattern).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
72
|
+
// Re-activate the wildcards: escaped '\*' → '.*', escaped '\?' → '.'.
|
|
73
|
+
const body = escaped.replace(/\\\*/g, '.*').replace(/\\\?/g, '.');
|
|
74
|
+
return new RegExp(`^${body}$`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Collect the set of token names that are RELEVANT to a set of components, so a
|
|
79
|
+
* slice can carry just those tokens instead of the full ~hundreds-of-tokens
|
|
80
|
+
* catalog (which would blow past the ~500-token budget). Relevance = a token
|
|
81
|
+
* whose name is referenced by a component's prop default/options, variant name,
|
|
82
|
+
* or the component name itself. When nothing is determinable we return an empty
|
|
83
|
+
* set and the slice renders component shape only — keeping the slice bounded
|
|
84
|
+
* regardless of catalog size (D-08 size guarantee).
|
|
85
|
+
*
|
|
86
|
+
* @param {Array} matched matched component entries (from collectComponents)
|
|
87
|
+
* @param {Array} tokens the full assembled token list
|
|
88
|
+
* @returns {Array} the subset of `tokens` referenced by the matched components
|
|
89
|
+
*/
|
|
90
|
+
function tokensForComponents(matched, tokens) {
|
|
91
|
+
if (!Array.isArray(tokens) || tokens.length === 0) return [];
|
|
92
|
+
// Build a haystack of strings drawn from the matched components.
|
|
93
|
+
const haystack = [];
|
|
94
|
+
for (const c of matched) {
|
|
95
|
+
if (c.name) haystack.push(c.name);
|
|
96
|
+
for (const v of c.variants || []) haystack.push(v);
|
|
97
|
+
for (const p of c.props || []) {
|
|
98
|
+
if (p.name) haystack.push(p.name);
|
|
99
|
+
if (p.default !== undefined) haystack.push(String(p.default));
|
|
100
|
+
for (const o of p.options || []) haystack.push(String(o));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const blob = haystack.join('\n');
|
|
104
|
+
return tokens.filter((t) => t && t.name !== undefined && blob.includes(t.name));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── helpers (Path A) ─────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/** Convert a Figma {r,g,b,a?} (0..1 floats) colour to a hex string. */
|
|
110
|
+
function rgbToHex({ r, g, b, a }) {
|
|
111
|
+
const to = (v) => Math.round(v * 255).toString(16).padStart(2, '0');
|
|
112
|
+
const hex = `#${to(r)}${to(g)}${to(b)}`;
|
|
113
|
+
return a !== undefined && a < 1 ? `${hex}${to(a)}` : hex;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Path A — extract tokens from a Figma Variables API body
|
|
118
|
+
* (`/v1/files/:key/variables/local`). Mirrors the spike's extractTokensFromVariables.
|
|
119
|
+
* @param {object|null} vars the Variables API response (has .meta.{variables,variableCollections})
|
|
120
|
+
* @returns {Array<{name,type,collection?,modes?}>}
|
|
121
|
+
*/
|
|
122
|
+
function extractTokensFromVariables(vars) {
|
|
123
|
+
if (!vars || !vars.meta) return [];
|
|
124
|
+
const collections = vars.meta.variableCollections || {};
|
|
125
|
+
const variables = vars.meta.variables || {};
|
|
126
|
+
const tokens = [];
|
|
127
|
+
for (const v of Object.values(variables)) {
|
|
128
|
+
const collection = collections[v.variableCollectionId];
|
|
129
|
+
const modes = collection?.modes || [];
|
|
130
|
+
const valuesByMode = {};
|
|
131
|
+
for (const mode of modes) {
|
|
132
|
+
const raw = v.valuesByMode?.[mode.modeId];
|
|
133
|
+
if (raw && typeof raw === 'object' && 'r' in raw) {
|
|
134
|
+
valuesByMode[mode.name] = rgbToHex(raw);
|
|
135
|
+
} else if (raw && raw.type === 'VARIABLE_ALIAS') {
|
|
136
|
+
valuesByMode[mode.name] = `{${variables[raw.id]?.name || raw.id}}`;
|
|
137
|
+
} else {
|
|
138
|
+
valuesByMode[mode.name] = raw;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
tokens.push({
|
|
142
|
+
name: v.name,
|
|
143
|
+
type: v.resolvedType,
|
|
144
|
+
collection: collection?.name,
|
|
145
|
+
modes: valuesByMode,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return tokens;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Path C — normalize a receiver-written plugin payload into the common token
|
|
153
|
+
* shape. The plugin (D-13) emits ALL local variables; we accept either a
|
|
154
|
+
* pre-shaped `tokens[]` array or the raw `variables`/`meta` form and pass it
|
|
155
|
+
* through extractTokensFromVariables when needed.
|
|
156
|
+
* @param {object|null} payload variables.json carrying source:'gdd-plugin'
|
|
157
|
+
* @returns {Array}
|
|
158
|
+
*/
|
|
159
|
+
function normalizePluginPayload(payload) {
|
|
160
|
+
if (!payload) return [];
|
|
161
|
+
// Preferred shape: the plugin already emits a flat tokens[] array.
|
|
162
|
+
if (Array.isArray(payload.tokens)) return payload.tokens;
|
|
163
|
+
// Fallback: it carries a Variables-API-like body — reuse Path A extraction.
|
|
164
|
+
if (payload.meta) return extractTokensFromVariables(payload);
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── three-path merge (D-04) ──────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Merge the three token sources by priority. On a NAME collision the
|
|
172
|
+
* higher-priority source wins.
|
|
173
|
+
*
|
|
174
|
+
* Implementation note: we iterate the priority chain HIGHEST-first and only set
|
|
175
|
+
* a name the first time we see it (skip-if-present), so the highest-priority
|
|
176
|
+
* source's entry is the one that survives.
|
|
177
|
+
*
|
|
178
|
+
* @param {object} opts
|
|
179
|
+
* @param {Array} [opts.variables] Path A tokens
|
|
180
|
+
* @param {Array} [opts.pluginVariables] Path C tokens
|
|
181
|
+
* @param {Array} [opts.styleTokens] Path B tokens
|
|
182
|
+
* @param {boolean} [opts.preferStyles] D-04 escape — move styles to the front
|
|
183
|
+
* @returns {Array} merged tokens (insertion order follows the priority chain)
|
|
184
|
+
*/
|
|
185
|
+
function assembleTokens({ variables, pluginVariables, styleTokens, preferStyles } = {}) {
|
|
186
|
+
const bySource = {
|
|
187
|
+
variables: Array.isArray(variables) ? variables : [],
|
|
188
|
+
plugin: Array.isArray(pluginVariables) ? pluginVariables : [],
|
|
189
|
+
styles: Array.isArray(styleTokens) ? styleTokens : [],
|
|
190
|
+
};
|
|
191
|
+
const priority = preferStyles
|
|
192
|
+
? ['styles', 'variables', 'plugin']
|
|
193
|
+
: DEFAULT_TOKEN_PRIORITY;
|
|
194
|
+
|
|
195
|
+
const merged = new Map();
|
|
196
|
+
for (const source of priority) {
|
|
197
|
+
for (const tok of bySource[source]) {
|
|
198
|
+
if (!tok || tok.name === undefined) continue;
|
|
199
|
+
if (!merged.has(tok.name)) merged.set(tok.name, tok);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return [...merged.values()];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── orchestrator ─────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
/** Read+parse a JSON file from the raw cache; return null if absent/unreadable. */
|
|
208
|
+
async function readJson(rawDir, name) {
|
|
209
|
+
try {
|
|
210
|
+
const body = await fs.readFile(path.join(rawDir, `${name}.json`), 'utf8');
|
|
211
|
+
return JSON.parse(body);
|
|
212
|
+
} catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Run the digest: read raw/ cache → walk (variant rollup) → 3-path token
|
|
219
|
+
* assembly → render DESIGN.md + write tokens.json + components.json.
|
|
220
|
+
*
|
|
221
|
+
* @param {object} opts
|
|
222
|
+
* @param {string} opts.rawDir raw/ cache dir produced by pull.cjs (31-01) — REQUIRED
|
|
223
|
+
* @param {string} opts.outDir dir to write DESIGN.md/tokens.json/components.json — REQUIRED for writes
|
|
224
|
+
* @param {Function} [opts.stylesResolver] fn(file, styles) → styleTokens[] (Path B; 31-03 provides real impl)
|
|
225
|
+
* @param {boolean} [opts.preferStyles] D-04 escape hatch
|
|
226
|
+
* @param {string} [opts.fetchedAtOverride] deterministic provenance header for tests
|
|
227
|
+
* @param {string} [opts.component] D-08 — name or glob; when set, render a
|
|
228
|
+
* per-component SLICE (~500 tokens) of only
|
|
229
|
+
* the matching component(s). Additive: when
|
|
230
|
+
* absent, the full-digest path is unchanged.
|
|
231
|
+
* @returns {Promise<object>}
|
|
232
|
+
* full: { ok:true, counts, bytes, outDir }
|
|
233
|
+
* sliced: { ok:true, sliced:true, matched:[names], counts:{components,tokens}, bytes, outDir, note? }
|
|
234
|
+
* error: { ok:false, error }
|
|
235
|
+
*/
|
|
236
|
+
async function digest({ rawDir, outDir, stylesResolver, preferStyles, fetchedAtOverride, component } = {}) {
|
|
237
|
+
if (!rawDir) {
|
|
238
|
+
return { ok: false, error: 'rawDir is required — run pull.cjs first' };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// (1) Required input — graceful guard (mirrors spike). NEVER throws.
|
|
242
|
+
const file = await readJson(rawDir, 'file');
|
|
243
|
+
if (!file) {
|
|
244
|
+
return { ok: false, error: 'raw/file.json not found — run pull.cjs first' };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// (2) Optional inputs.
|
|
248
|
+
const variablesRaw = await readJson(rawDir, 'variables');
|
|
249
|
+
const styles = await readJson(rawDir, 'styles');
|
|
250
|
+
const meta = await readJson(rawDir, '_meta');
|
|
251
|
+
|
|
252
|
+
// Distinguish Path A (Variables API body) from Path C (receiver plugin payload)
|
|
253
|
+
// by the receiver marker. Only one of the two is populated from variables.json.
|
|
254
|
+
let apiVariables = null;
|
|
255
|
+
let pluginPayload = null;
|
|
256
|
+
if (variablesRaw && variablesRaw.source === PLUGIN_PAYLOAD_MARKER) {
|
|
257
|
+
pluginPayload = variablesRaw; // Path C
|
|
258
|
+
} else {
|
|
259
|
+
apiVariables = variablesRaw; // Path A (may be null)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// (3) Components + widgets — variant rollup is default-on (D-02).
|
|
263
|
+
const { components, widgets } = collectComponents(file.document);
|
|
264
|
+
|
|
265
|
+
// (4) Three token paths.
|
|
266
|
+
const pathATokens = extractTokensFromVariables(apiVariables);
|
|
267
|
+
const styleTokens = stylesResolver ? await stylesResolver(file, styles) : [];
|
|
268
|
+
const pluginVariables = normalizePluginPayload(pluginPayload);
|
|
269
|
+
|
|
270
|
+
// (5) Merge by priority (D-04).
|
|
271
|
+
const tokens = assembleTokens({
|
|
272
|
+
variables: pathATokens,
|
|
273
|
+
pluginVariables,
|
|
274
|
+
styleTokens,
|
|
275
|
+
preferStyles,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// (6) Provenance — fetched_at is injectable for deterministic output.
|
|
279
|
+
const fileMeta = {
|
|
280
|
+
file_key: meta?.file_key,
|
|
281
|
+
fetched_at: fetchedAtOverride !== undefined ? fetchedAtOverride : meta?.fetched_at,
|
|
282
|
+
name: file.name,
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// (6b) D-08 — per-component SLICE. When `component` is provided we short-circuit
|
|
286
|
+
// the full digest and render only the matching component(s) + their relevant
|
|
287
|
+
// tokens. This is additive: the block below is skipped entirely when `component`
|
|
288
|
+
// is undefined, so the full-digest path (step 7) stays byte-identical.
|
|
289
|
+
if (component !== undefined && component !== null && component !== '') {
|
|
290
|
+
const rx = globToRegExp(component);
|
|
291
|
+
const matched = components.filter((c) => rx.test(c.name));
|
|
292
|
+
// Only tokens referenced by the matched components — keeps the slice ~500
|
|
293
|
+
// tokens instead of dumping the whole catalog.
|
|
294
|
+
const sliceTokens = tokensForComponents(matched, tokens);
|
|
295
|
+
const sliceMd = renderDesignMd({
|
|
296
|
+
tokens: sliceTokens,
|
|
297
|
+
components: matched,
|
|
298
|
+
widgets: [], // a per-component slice omits page/widget noise
|
|
299
|
+
fileMeta,
|
|
300
|
+
});
|
|
301
|
+
if (outDir) {
|
|
302
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
303
|
+
// Write the slice to DESIGN.md (the SKILL/e2e read whatever digest writes).
|
|
304
|
+
await fs.writeFile(path.join(outDir, 'DESIGN.md'), sliceMd);
|
|
305
|
+
}
|
|
306
|
+
const result = {
|
|
307
|
+
ok: true,
|
|
308
|
+
sliced: true,
|
|
309
|
+
matched: matched.map((c) => c.name),
|
|
310
|
+
counts: { components: matched.length, tokens: sliceTokens.length },
|
|
311
|
+
bytes: { designMd: Buffer.byteLength(sliceMd, 'utf8') },
|
|
312
|
+
outDir,
|
|
313
|
+
};
|
|
314
|
+
if (matched.length === 0) result.note = `no component matched ${component}`;
|
|
315
|
+
return result;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// (7) Render + write artifacts (D-09: digest/ is commit-able).
|
|
319
|
+
const designMd = renderDesignMd({ tokens, components, widgets, fileMeta });
|
|
320
|
+
const tokensJson = JSON.stringify(tokens, null, 2);
|
|
321
|
+
const componentsJson = JSON.stringify(components, null, 2);
|
|
322
|
+
|
|
323
|
+
if (outDir) {
|
|
324
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
325
|
+
await fs.writeFile(path.join(outDir, 'DESIGN.md'), designMd);
|
|
326
|
+
await fs.writeFile(path.join(outDir, 'tokens.json'), tokensJson);
|
|
327
|
+
await fs.writeFile(path.join(outDir, 'components.json'), componentsJson);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
ok: true,
|
|
332
|
+
counts: {
|
|
333
|
+
tokens: tokens.length,
|
|
334
|
+
components: components.length,
|
|
335
|
+
widgets: widgets.length,
|
|
336
|
+
},
|
|
337
|
+
bytes: {
|
|
338
|
+
designMd: Buffer.byteLength(designMd, 'utf8'),
|
|
339
|
+
tokensJson: Buffer.byteLength(tokensJson, 'utf8'),
|
|
340
|
+
componentsJson: Buffer.byteLength(componentsJson, 'utf8'),
|
|
341
|
+
},
|
|
342
|
+
outDir,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
module.exports = {
|
|
347
|
+
digest,
|
|
348
|
+
assembleTokens,
|
|
349
|
+
DEFAULT_TOKEN_PRIORITY,
|
|
350
|
+
// exported for unit reuse / downstream (31-08 --component, 31-03 normalization parity)
|
|
351
|
+
extractTokensFromVariables,
|
|
352
|
+
normalizePluginPayload,
|
|
353
|
+
PLUGIN_PAYLOAD_MARKER,
|
|
354
|
+
// D-08 component filter — exported for unit reuse / downstream slicing tools.
|
|
355
|
+
globToRegExp,
|
|
356
|
+
tokensForComponents,
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// ── CLI entry (31-08) ──────────────────────────────────────────────────────────
|
|
360
|
+
//
|
|
361
|
+
// Thin argv wrapper invoked by the figma-extract SKILL (31-07) and the e2e test
|
|
362
|
+
// (31-10). The callable API (digest/assembleTokens/…) above is the import surface;
|
|
363
|
+
// this block runs ONLY when the file is executed directly. Flag → option map
|
|
364
|
+
// (contract from 31-02 SUMMARY, extended with --component for D-08):
|
|
365
|
+
//
|
|
366
|
+
// --raw <dir> rawDir (required) raw cache dir written by pull.cjs
|
|
367
|
+
// --out <dir> outDir (required) digest artifact output dir
|
|
368
|
+
// --prefer-styles preferStyles:true D-04 escape — styles-first priority
|
|
369
|
+
// --component <name> component D-08 per-component slice (name or glob)
|
|
370
|
+
//
|
|
371
|
+
// D-10: this block NEVER reads, logs, or persists FIGMA_TOKEN — digest is offline
|
|
372
|
+
// and token-free by construction; the CLI only echoes counts/paths.
|
|
373
|
+
|
|
374
|
+
/** Minimal flag parser for the four supported options (no external dep). */
|
|
375
|
+
function parseArgv(argv) {
|
|
376
|
+
const opts = {};
|
|
377
|
+
for (let i = 0; i < argv.length; i++) {
|
|
378
|
+
const a = argv[i];
|
|
379
|
+
if (a === '--raw') opts.rawDir = argv[++i];
|
|
380
|
+
else if (a === '--out') opts.outDir = argv[++i];
|
|
381
|
+
else if (a === '--prefer-styles') opts.preferStyles = true;
|
|
382
|
+
else if (a === '--component') opts.component = argv[++i];
|
|
383
|
+
else if (a === '--help' || a === '-h') opts.help = true;
|
|
384
|
+
}
|
|
385
|
+
return opts;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const CLI_USAGE =
|
|
389
|
+
'Usage: node digest.cjs --raw <dir> --out <dir> [--prefer-styles] [--component <name|glob>]';
|
|
390
|
+
|
|
391
|
+
if (require.main === module) {
|
|
392
|
+
(async () => {
|
|
393
|
+
const opts = parseArgv(process.argv.slice(2));
|
|
394
|
+
if (opts.help) {
|
|
395
|
+
process.stdout.write(`${CLI_USAGE}\n`);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (!opts.rawDir || !opts.outDir) {
|
|
399
|
+
process.stderr.write(`${CLI_USAGE}\n--raw and --out are required.\n`);
|
|
400
|
+
process.exitCode = 2;
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const res = await digest({
|
|
404
|
+
rawDir: opts.rawDir,
|
|
405
|
+
outDir: opts.outDir,
|
|
406
|
+
preferStyles: opts.preferStyles,
|
|
407
|
+
component: opts.component,
|
|
408
|
+
});
|
|
409
|
+
if (!res.ok) {
|
|
410
|
+
process.stderr.write(`digest failed: ${res.error}\n`);
|
|
411
|
+
process.exitCode = 1;
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (res.sliced) {
|
|
415
|
+
const summary =
|
|
416
|
+
res.matched.length > 0
|
|
417
|
+
? `sliced ${res.counts.components} component(s): ${res.matched.join(', ')} (${res.counts.tokens} tokens) → ${res.outDir}`
|
|
418
|
+
: `${res.note} → ${res.outDir} (empty slice)`;
|
|
419
|
+
process.stdout.write(`${summary}\n`);
|
|
420
|
+
} else {
|
|
421
|
+
process.stdout.write(
|
|
422
|
+
`digest ok: ${res.counts.components} components, ${res.counts.tokens} tokens, ${res.counts.widgets} widgets → ${res.outDir}\n`
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
})().catch((err) => {
|
|
426
|
+
// Never leak a token; surface only the message.
|
|
427
|
+
process.stderr.write(`digest error: ${err && err.message ? err.message : err}\n`);
|
|
428
|
+
process.exitCode = 1;
|
|
429
|
+
});
|
|
430
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// scripts/lib/figma-extract/parse-url.cjs — Plan 31-01 (Wave A.1)
|
|
3
|
+
//
|
|
4
|
+
// Figma file URL/key parser. Accepts either a bare file key or a full Figma
|
|
5
|
+
// file URL and returns the canonical file key. Both legacy `/file/<key>/...`
|
|
6
|
+
// and newer `/design/<key>/...` URL forms are supported.
|
|
7
|
+
//
|
|
8
|
+
// CommonJS, zero external dependencies. Pure function — no I/O, no logging.
|
|
9
|
+
//
|
|
10
|
+
// Examples:
|
|
11
|
+
// parseFigmaFileKey('IAHNrYoqIh56SCxgv3PjCS')
|
|
12
|
+
// → 'IAHNrYoqIh56SCxgv3PjCS' (bare-key passthrough)
|
|
13
|
+
// parseFigmaFileKey('https://www.figma.com/file/IAHNrYoqIh56SCxgv3PjCS/My-DS?node-id=0-1')
|
|
14
|
+
// → 'IAHNrYoqIh56SCxgv3PjCS'
|
|
15
|
+
// parseFigmaFileKey('https://www.figma.com/design/IAHNrYoqIh56SCxgv3PjCS/My-DS')
|
|
16
|
+
// → 'IAHNrYoqIh56SCxgv3PjCS'
|
|
17
|
+
// parseFigmaFileKey('') → throws TypeError
|
|
18
|
+
// parseFigmaFileKey('https://example.com/no-key') → throws Error
|
|
19
|
+
|
|
20
|
+
// Matches the key segment after `/file/` or `/design/` in a Figma URL.
|
|
21
|
+
// Figma file keys are URL-safe base62-ish tokens ([A-Za-z0-9]).
|
|
22
|
+
const URL_KEY_RE = /(?:file|design)\/([A-Za-z0-9]+)/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a Figma file key from a bare key or a full Figma file URL.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} input - bare key | https://www.figma.com/file/<key>/... | .../design/<key>/...
|
|
28
|
+
* @returns {string} the extracted file key
|
|
29
|
+
* @throws {TypeError} when input is missing / empty / not a string
|
|
30
|
+
* @throws {Error} when input looks like a URL but no file key can be extracted
|
|
31
|
+
*/
|
|
32
|
+
function parseFigmaFileKey(input) {
|
|
33
|
+
if (typeof input !== 'string') {
|
|
34
|
+
throw new TypeError('parseFigmaFileKey: non-empty input (file key or URL) required');
|
|
35
|
+
}
|
|
36
|
+
const trimmed = input.trim();
|
|
37
|
+
if (trimmed === '') {
|
|
38
|
+
throw new TypeError('parseFigmaFileKey: non-empty input (file key or URL) required');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// URL form carrying an http(s) scheme: validate the HOST is figma.com via the
|
|
42
|
+
// URL parser — NOT a substring check. A substring test (`includes('figma.com')`)
|
|
43
|
+
// is unsafe: `https://figma.com.evil.test/...` and `https://evil.test/figma.com`
|
|
44
|
+
// both contain the literal but are not Figma (CodeQL js/incomplete-url-substring-
|
|
45
|
+
// sanitization). new URL().hostname gives the real authority to compare exactly.
|
|
46
|
+
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed);
|
|
47
|
+
if (hasScheme) {
|
|
48
|
+
let host;
|
|
49
|
+
try {
|
|
50
|
+
host = new URL(trimmed).hostname.toLowerCase();
|
|
51
|
+
} catch {
|
|
52
|
+
throw new Error('parseFigmaFileKey: malformed URL: ' + trimmed);
|
|
53
|
+
}
|
|
54
|
+
const isFigmaHost =
|
|
55
|
+
host === 'figma.com' || host === 'www.figma.com' || host.endsWith('.figma.com');
|
|
56
|
+
if (!isFigmaHost) {
|
|
57
|
+
throw new Error('parseFigmaFileKey: not a figma.com URL: ' + trimmed);
|
|
58
|
+
}
|
|
59
|
+
const match = trimmed.match(URL_KEY_RE);
|
|
60
|
+
if (!match) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
'parseFigmaFileKey: could not extract a Figma file key from URL: ' + trimmed
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return match[1];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Scheme-less path form (e.g. `www.figma.com/file/<key>` or `/design/<key>/...`):
|
|
69
|
+
// there is no parseable authority, so extract the key from the path segment.
|
|
70
|
+
if (trimmed.includes('/')) {
|
|
71
|
+
const match = trimmed.match(URL_KEY_RE);
|
|
72
|
+
if (!match) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
'parseFigmaFileKey: could not extract a Figma file key from URL: ' + trimmed
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return match[1];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Bare key — a single URL-safe base62-ish token (no scheme, no path).
|
|
81
|
+
if (!/^[A-Za-z0-9]+$/.test(trimmed)) {
|
|
82
|
+
throw new Error('parseFigmaFileKey: invalid Figma file key: ' + trimmed);
|
|
83
|
+
}
|
|
84
|
+
return trimmed;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { parseFigmaFileKey };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://get-design-done/figma-extract/payload-schema.json",
|
|
4
|
+
"title": "GDD Sync plugin variables payload (Path C contract — D-04, D-13)",
|
|
5
|
+
"description": "The JSON body the Figma plugin 'GDD Sync' (31-05) POSTs to the localhost receiver (31-06) at 127.0.0.1:5179/variables. Carries ALL local variables (D-13); filtering happens later in digest.cjs (31-02). The top-level `source` const is the marker digest.cjs keys on to route this file to Path C. This schema is the source of truth that figma-plugin/src/payload-schema.ts mirrors.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["source", "collections", "variables"],
|
|
8
|
+
"additionalProperties": true,
|
|
9
|
+
"properties": {
|
|
10
|
+
"source": {
|
|
11
|
+
"const": "gdd-plugin",
|
|
12
|
+
"description": "Path C marker. digest.cjs (31-02) routes a variables.json carrying this top-level field to the plugin path (NOT the Variables-API path)."
|
|
13
|
+
},
|
|
14
|
+
"fileKey": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"description": "Optional Figma file key the variables were read from (provenance only)."
|
|
17
|
+
},
|
|
18
|
+
"exportedAt": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"description": "Optional ISO-8601 timestamp of when the plugin exported the payload."
|
|
21
|
+
},
|
|
22
|
+
"collections": {
|
|
23
|
+
"type": "array",
|
|
24
|
+
"description": "Local variable collections. Each carries its modes so digest can label valuesByMode.",
|
|
25
|
+
"items": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"required": ["id", "name", "modes"],
|
|
28
|
+
"additionalProperties": true,
|
|
29
|
+
"properties": {
|
|
30
|
+
"id": { "type": "string" },
|
|
31
|
+
"name": { "type": "string" },
|
|
32
|
+
"modes": {
|
|
33
|
+
"type": "array",
|
|
34
|
+
"items": {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"required": ["modeId", "name"],
|
|
37
|
+
"additionalProperties": true,
|
|
38
|
+
"properties": {
|
|
39
|
+
"modeId": { "type": "string" },
|
|
40
|
+
"name": { "type": "string" }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"variables": {
|
|
48
|
+
"type": "array",
|
|
49
|
+
"description": "ALL local variables (D-13 — published flag is NOT required; digest filters). Each value per mode is either a resolved primitive/color OR an alias reference.",
|
|
50
|
+
"items": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"required": ["id", "name", "resolvedType", "collectionId", "valuesByMode"],
|
|
53
|
+
"additionalProperties": true,
|
|
54
|
+
"properties": {
|
|
55
|
+
"id": { "type": "string" },
|
|
56
|
+
"name": { "type": "string" },
|
|
57
|
+
"resolvedType": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"enum": ["COLOR", "FLOAT", "STRING", "BOOLEAN"]
|
|
60
|
+
},
|
|
61
|
+
"collectionId": { "type": "string" },
|
|
62
|
+
"valuesByMode": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"description": "Keyed by modeId. A value is a resolved primitive (number/string/boolean), a resolved color object {r,g,b,a?}, or an alias marker ({type:'VARIABLE_ALIAS', id} or {alias:<name>}).",
|
|
65
|
+
"additionalProperties": {
|
|
66
|
+
"oneOf": [
|
|
67
|
+
{ "type": "number" },
|
|
68
|
+
{ "type": "string" },
|
|
69
|
+
{ "type": "boolean" },
|
|
70
|
+
{
|
|
71
|
+
"type": "object",
|
|
72
|
+
"description": "Resolved color (Figma 0..1 floats).",
|
|
73
|
+
"required": ["r", "g", "b"],
|
|
74
|
+
"additionalProperties": true,
|
|
75
|
+
"properties": {
|
|
76
|
+
"r": { "type": "number" },
|
|
77
|
+
"g": { "type": "number" },
|
|
78
|
+
"b": { "type": "number" },
|
|
79
|
+
"a": { "type": "number" }
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"type": "object",
|
|
84
|
+
"description": "Alias reference to another variable id.",
|
|
85
|
+
"required": ["type", "id"],
|
|
86
|
+
"additionalProperties": true,
|
|
87
|
+
"properties": {
|
|
88
|
+
"type": { "const": "VARIABLE_ALIAS" },
|
|
89
|
+
"id": { "type": "string" }
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"type": "object",
|
|
94
|
+
"description": "Alias reference by name.",
|
|
95
|
+
"required": ["alias"],
|
|
96
|
+
"additionalProperties": true,
|
|
97
|
+
"properties": {
|
|
98
|
+
"alias": { "type": "string" }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|