@hegemonart/get-design-done 1.30.6 → 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 +61 -0
- package/README.md +21 -0
- package/SKILL.md +1 -0
- package/package.json +5 -2
- 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/health-mirror/index.cjs +88 -1
- package/skills/figma-extract/SKILL.md +64 -0
- package/skills/health/SKILL.md +10 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// scripts/lib/figma-extract/receiver.cjs — Plan 31-06 (Wave B.3)
|
|
3
|
+
// Path C receiver; D-06: ephemeral + 127.0.0.1-only.
|
|
4
|
+
//
|
|
5
|
+
// The localhost half of Path C (D-04). The Figma plugin "GDD Sync" (31-05)
|
|
6
|
+
// reads `figma.variables` from inside Figma (works on any plan — sidesteps the
|
|
7
|
+
// spike's Variables-API-403 Enterprise blocker) and POSTs them here. This
|
|
8
|
+
// receiver validates the payload against payload-schema.json and writes it into
|
|
9
|
+
// the raw/ cache as variables.json, where digest.cjs (31-02) consumes it as
|
|
10
|
+
// Path C via the `source:'gdd-plugin'` marker.
|
|
11
|
+
//
|
|
12
|
+
// Security properties are the WHOLE point (D-06):
|
|
13
|
+
// - Binds 127.0.0.1 ONLY (host '127.0.0.1', never 0.0.0.0) — unreachable off
|
|
14
|
+
// the loopback interface.
|
|
15
|
+
// - REFUSES any non-loopback remote with 403 (req.socket.remoteAddress gate),
|
|
16
|
+
// even though the bind already makes that essentially unreachable — defense
|
|
17
|
+
// in depth, and asserted by test via a mocked remote address.
|
|
18
|
+
// - Validates EVERY payload against the schema BEFORE touching disk (400 on
|
|
19
|
+
// invalid; nothing written).
|
|
20
|
+
// - Port is HARDCODED to 5179 — NOT read from env or a CLI flag (acceptance
|
|
21
|
+
// criterion). Changing it requires a code edit. There is intentionally no
|
|
22
|
+
// `process.env.*PORT*` read in this module.
|
|
23
|
+
// - EPHEMERAL: listens only for the duration of one extract run and exits on
|
|
24
|
+
// the FIRST valid receipt OR on a timeout — never a lingering open port.
|
|
25
|
+
//
|
|
26
|
+
// D-10: this module handles design variables ONLY. It NEVER touches the Figma
|
|
27
|
+
// token (that's a REST-path concern, not Path C). There is no secret-handling
|
|
28
|
+
// code here, and the logger seam receives lifecycle events + counts only —
|
|
29
|
+
// never full payloads.
|
|
30
|
+
|
|
31
|
+
const http = require('node:http');
|
|
32
|
+
const fs = require('node:fs/promises');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
const Ajv = require('ajv');
|
|
35
|
+
|
|
36
|
+
const payloadSchema = require('./payload-schema.json');
|
|
37
|
+
|
|
38
|
+
// ── constants (D-06 acceptance criterion: hardcoded, no env override) ─────────
|
|
39
|
+
const RECEIVER_HOST = '127.0.0.1'; // loopback ONLY
|
|
40
|
+
const RECEIVER_PORT = 5179; // HARDCODED — intentionally not read from process.env
|
|
41
|
+
|
|
42
|
+
// The marker digest.cjs (31-02) keys on to route variables.json to Path C.
|
|
43
|
+
const PLUGIN_PAYLOAD_MARKER = 'gdd-plugin';
|
|
44
|
+
|
|
45
|
+
// Defensive body cap. Large design systems can ship sizeable variable sets
|
|
46
|
+
// (the risk register notes streaming for the raw pull); 50MB is generous for a
|
|
47
|
+
// variables-only JSON payload while still bounding memory from a hostile body.
|
|
48
|
+
const MAX_BODY_BYTES = 50 * 1024 * 1024;
|
|
49
|
+
|
|
50
|
+
// ── validator (Ajv is a hard repo dependency — package.json "ajv": "^8.18.0") ─
|
|
51
|
+
// Compiled once at module load. Ajv 8 CJS: require('ajv') is the constructor.
|
|
52
|
+
// NOTE: fail-fast (default, NO allErrors). The receiver validates an UNTRUSTED
|
|
53
|
+
// HTTP body from the plugin; `allErrors: true` would walk the entire (possibly
|
|
54
|
+
// hostile, deeply-nested) object collecting every violation — a resource-
|
|
55
|
+
// exhaustion / DoS amplifier (CodeQL js/resources-exhaustion). Fail-fast stops
|
|
56
|
+
// at the first violation, which is all the 400 response needs. The 50MB
|
|
57
|
+
// MAX_BODY_BYTES cap bounds input size; fail-fast bounds traversal cost.
|
|
58
|
+
const AjvCtor = Ajv.default || Ajv;
|
|
59
|
+
const _ajv = new AjvCtor({ strict: false });
|
|
60
|
+
const _validate = _ajv.compile(payloadSchema);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Validate a parsed body against payload-schema.json.
|
|
64
|
+
* @param {*} body
|
|
65
|
+
* @returns {{ valid: boolean, errors: Array }}
|
|
66
|
+
*/
|
|
67
|
+
function validatePayload(body) {
|
|
68
|
+
const valid = _validate(body) === true;
|
|
69
|
+
return { valid, errors: valid ? [] : (_validate.errors || []) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Normalize req.socket.remoteAddress to a loopback test (IPv4, IPv6, mapped). */
|
|
73
|
+
function isLoopbackRemote(remoteAddress) {
|
|
74
|
+
return (
|
|
75
|
+
remoteAddress === '127.0.0.1' ||
|
|
76
|
+
remoteAddress === '::1' ||
|
|
77
|
+
remoteAddress === '::ffff:127.0.0.1'
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Read the full request body with a hard size cap. Rejects on overflow. */
|
|
82
|
+
function readBody(req) {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const chunks = [];
|
|
85
|
+
let size = 0;
|
|
86
|
+
req.on('data', (chunk) => {
|
|
87
|
+
size += chunk.length;
|
|
88
|
+
if (size > MAX_BODY_BYTES) {
|
|
89
|
+
reject(new Error('payload too large'));
|
|
90
|
+
req.destroy();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
chunks.push(chunk);
|
|
94
|
+
});
|
|
95
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
96
|
+
req.on('error', reject);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** No-op logger fallback. The real seam receives lifecycle events + counts only. */
|
|
101
|
+
function emit(logger, event) {
|
|
102
|
+
if (typeof logger === 'function') {
|
|
103
|
+
try {
|
|
104
|
+
logger(event);
|
|
105
|
+
} catch {
|
|
106
|
+
/* a broken logger must never crash the receiver */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build the request handler. Exported (`createHandler`) so tests can exercise
|
|
113
|
+
* the loopback gate / routing synchronously by invoking it with a fake req/res
|
|
114
|
+
* — no real remote socket needed (D-06 refusal path is asserted this way).
|
|
115
|
+
*
|
|
116
|
+
* The `onReceipt(parsed, filePath)` callback is invoked exactly once, on the
|
|
117
|
+
* first VALID localhost POST, AFTER the file is written. startReceiver wires it
|
|
118
|
+
* to close the server + resolve. Non-localhost (403), bad route (404), parse
|
|
119
|
+
* error / schema-invalid (400) NEVER call onReceipt — the server keeps waiting.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} opts
|
|
122
|
+
* @param {string} opts.outDir
|
|
123
|
+
* @param {Function} [opts.logger]
|
|
124
|
+
* @param {Function} opts.onReceipt async (parsed, filePath) => void
|
|
125
|
+
* @returns {Function} (req, res) => void
|
|
126
|
+
*/
|
|
127
|
+
function createHandler({ outDir, logger, onReceipt }) {
|
|
128
|
+
return function handler(req, res) {
|
|
129
|
+
// (1) Loopback gate FIRST (D-06) — defense in depth on top of the bind.
|
|
130
|
+
const remoteAddress = req.socket && req.socket.remoteAddress;
|
|
131
|
+
if (!isLoopbackRemote(remoteAddress)) {
|
|
132
|
+
emit(logger, { event: 'reject-403', reason: 'non-localhost' });
|
|
133
|
+
res.writeHead(403, { 'content-type': 'text/plain' });
|
|
134
|
+
res.end('forbidden: non-localhost');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// (2) Route — only POST /variables is handled.
|
|
139
|
+
if (req.method !== 'POST' || req.url !== '/variables') {
|
|
140
|
+
emit(logger, { event: 'reject-404', method: req.method, url: req.url });
|
|
141
|
+
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
142
|
+
res.end('not found');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// (3) Read + parse + validate + write. Any thrown error → 500 (no crash).
|
|
147
|
+
readBody(req)
|
|
148
|
+
.then(async (raw) => {
|
|
149
|
+
let parsed;
|
|
150
|
+
try {
|
|
151
|
+
parsed = JSON.parse(raw);
|
|
152
|
+
} catch {
|
|
153
|
+
emit(logger, { event: 'reject-400', reason: 'malformed-json' });
|
|
154
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
155
|
+
res.end(JSON.stringify({ error: 'malformed-json' }));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const { valid, errors } = validatePayload(parsed);
|
|
160
|
+
if (!valid) {
|
|
161
|
+
emit(logger, { event: 'reject-400', reason: 'schema', errorCount: errors.length });
|
|
162
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
163
|
+
res.end(JSON.stringify({ error: 'schema', details: errors }));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Valid. Stamp the marker if (defensively) absent, then write the file.
|
|
168
|
+
if (parsed.source !== PLUGIN_PAYLOAD_MARKER) parsed.source = PLUGIN_PAYLOAD_MARKER;
|
|
169
|
+
const filePath = path.join(outDir, 'variables.json');
|
|
170
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
171
|
+
await fs.writeFile(filePath, JSON.stringify(parsed));
|
|
172
|
+
|
|
173
|
+
emit(logger, {
|
|
174
|
+
event: 'receipt',
|
|
175
|
+
path: filePath,
|
|
176
|
+
collections: Array.isArray(parsed.collections) ? parsed.collections.length : 0,
|
|
177
|
+
variables: Array.isArray(parsed.variables) ? parsed.variables.length : 0,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
181
|
+
res.end(JSON.stringify({ ok: true }));
|
|
182
|
+
|
|
183
|
+
if (typeof onReceipt === 'function') await onReceipt(parsed, filePath);
|
|
184
|
+
})
|
|
185
|
+
.catch((err) => {
|
|
186
|
+
// Body-too-large or unexpected I/O error. Do NOT leak internals; do NOT
|
|
187
|
+
// resolve the receipt. 500 keeps the server waiting for a retry.
|
|
188
|
+
emit(logger, { event: 'error', message: err && err.message });
|
|
189
|
+
if (!res.headersSent) {
|
|
190
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
191
|
+
res.end(JSON.stringify({ error: 'internal' }));
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Start the ephemeral Path C receiver.
|
|
199
|
+
*
|
|
200
|
+
* @param {object} opts
|
|
201
|
+
* @param {string} opts.outDir REQUIRED — writes <outDir>/variables.json on valid receipt
|
|
202
|
+
* @param {number} [opts.timeoutMs=120000] exits if no valid payload arrives in time
|
|
203
|
+
* @param {Function} [opts.logger] structured lifecycle sink (never receives secrets/full payloads)
|
|
204
|
+
* @returns {Promise<{received:true, path:string} | {received:false, reason:'timeout'}>}
|
|
205
|
+
*
|
|
206
|
+
* Resolves with `{received:true, path}` on the FIRST valid POST /variables, or
|
|
207
|
+
* `{received:false, reason:'timeout'}` on timeout. The server is closed on BOTH
|
|
208
|
+
* exit paths (ephemeral — D-06). Non-localhost → 403; schema-invalid → 400;
|
|
209
|
+
* neither resolves the promise (the server keeps waiting until receipt/timeout).
|
|
210
|
+
*/
|
|
211
|
+
function startReceiver({ outDir, timeoutMs = 120000, logger } = {}) {
|
|
212
|
+
if (!outDir) {
|
|
213
|
+
return Promise.reject(new TypeError('startReceiver: opts.outDir is required'));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return new Promise((resolve, reject) => {
|
|
217
|
+
let settled = false;
|
|
218
|
+
let timer = null;
|
|
219
|
+
let server = null;
|
|
220
|
+
|
|
221
|
+
const finish = (result) => {
|
|
222
|
+
if (settled) return;
|
|
223
|
+
settled = true;
|
|
224
|
+
if (timer) {
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
timer = null;
|
|
227
|
+
}
|
|
228
|
+
// Close the server on BOTH exit paths so the port is never left open and
|
|
229
|
+
// the event loop can drain (process can exit). close() is idempotent-safe.
|
|
230
|
+
if (server) server.close(() => resolve(result));
|
|
231
|
+
else resolve(result);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const handler = createHandler({
|
|
235
|
+
outDir,
|
|
236
|
+
logger,
|
|
237
|
+
onReceipt: (_parsed, filePath) => finish({ received: true, path: filePath }),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
server = http.createServer(handler);
|
|
241
|
+
|
|
242
|
+
server.on('error', (err) => {
|
|
243
|
+
// Most likely EADDRINUSE (another receiver already bound 5179) — surface
|
|
244
|
+
// it to the caller rather than hanging. Only meaningful before listen.
|
|
245
|
+
if (!settled) {
|
|
246
|
+
settled = true;
|
|
247
|
+
if (timer) clearTimeout(timer);
|
|
248
|
+
reject(err);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
server.listen(RECEIVER_PORT, RECEIVER_HOST, () => {
|
|
253
|
+
emit(logger, { event: 'listen', host: RECEIVER_HOST, port: RECEIVER_PORT });
|
|
254
|
+
// Arm the timeout only once we are actually listening.
|
|
255
|
+
timer = setTimeout(() => {
|
|
256
|
+
emit(logger, { event: 'timeout', timeoutMs });
|
|
257
|
+
finish({ received: false, reason: 'timeout' });
|
|
258
|
+
}, timeoutMs);
|
|
259
|
+
// Don't let the timeout itself keep the process alive past its purpose.
|
|
260
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = {
|
|
266
|
+
startReceiver,
|
|
267
|
+
createHandler,
|
|
268
|
+
validatePayload,
|
|
269
|
+
isLoopbackRemote,
|
|
270
|
+
RECEIVER_PORT,
|
|
271
|
+
RECEIVER_HOST,
|
|
272
|
+
PLUGIN_PAYLOAD_MARKER,
|
|
273
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Plan 31-02 — productionized from spike 001 digest.mjs buildDesignMd().
|
|
4
|
+
*
|
|
5
|
+
* Deterministic DESIGN.md renderer with a STABLE section order:
|
|
6
|
+
* header (provenance) →
|
|
7
|
+
* ## Tokens (### Color, ### Typography, ### Other — only when non-empty) →
|
|
8
|
+
* ## Components (Total line; sets first, then ### Singleton components) →
|
|
9
|
+
* ## Widgets / Pages
|
|
10
|
+
*
|
|
11
|
+
* Determinism guarantee: identical {tokens, components, widgets, fileMeta} input
|
|
12
|
+
* produces BYTE-IDENTICAL output. The ONLY nondeterministic value is the
|
|
13
|
+
* provenance line's `fetched_at`, which the caller injects (tests pass a fixed
|
|
14
|
+
* value). This module NEVER calls new Date()/Date.now() — required for 31-10's
|
|
15
|
+
* golden-snapshot baseline.
|
|
16
|
+
*
|
|
17
|
+
* Pure CommonJS, no external deps, no I/O.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// Size-bounding slice caps (carried over verbatim from the spike for parity).
|
|
21
|
+
const CAP_COLOR = 200;
|
|
22
|
+
const CAP_TYPOGRAPHY = 100;
|
|
23
|
+
const CAP_OTHER = 100;
|
|
24
|
+
const CAP_VARIANTS = 20;
|
|
25
|
+
const CAP_SINGLETONS = 100;
|
|
26
|
+
const CAP_WIDGETS = 50;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} input
|
|
30
|
+
* @param {Array} input.tokens assembled tokens [{name,type,collection?,modes?,value?,description?}]
|
|
31
|
+
* @param {Array} input.components from walk.cjs collectComponents().components
|
|
32
|
+
* @param {Array} input.widgets from walk.cjs collectComponents().widgets
|
|
33
|
+
* @param {object} input.fileMeta { file_key, fetched_at, name } — fetched_at is the only injected nondeterminism
|
|
34
|
+
* @returns {string} DESIGN.md body
|
|
35
|
+
*/
|
|
36
|
+
function renderDesignMd({ tokens, components, widgets, fileMeta }) {
|
|
37
|
+
const toks = Array.isArray(tokens) ? tokens : [];
|
|
38
|
+
const comps = Array.isArray(components) ? components : [];
|
|
39
|
+
const wids = Array.isArray(widgets) ? widgets : [];
|
|
40
|
+
const meta = fileMeta || {};
|
|
41
|
+
|
|
42
|
+
const colorTokens = toks.filter((t) => t.type === 'COLOR' || t.type === 'FILL');
|
|
43
|
+
const textTokens = toks.filter((t) => t.type === 'TEXT');
|
|
44
|
+
const otherTokens = toks.filter(
|
|
45
|
+
(t) => !['COLOR', 'FILL', 'TEXT'].includes(t.type)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const lines = [];
|
|
49
|
+
lines.push(`# DESIGN.md`);
|
|
50
|
+
lines.push(``);
|
|
51
|
+
lines.push(
|
|
52
|
+
`> Auto-generated from Figma file \`${meta.file_key}\` at ${meta.fetched_at}`
|
|
53
|
+
);
|
|
54
|
+
lines.push(`> Source: ${meta.name || 'Design system'}`);
|
|
55
|
+
lines.push(``);
|
|
56
|
+
|
|
57
|
+
// ── ## Tokens ──────────────────────────────────────────────────────────────
|
|
58
|
+
lines.push(`## Tokens`);
|
|
59
|
+
lines.push(``);
|
|
60
|
+
|
|
61
|
+
if (colorTokens.length) {
|
|
62
|
+
lines.push(`### Color`);
|
|
63
|
+
lines.push(``);
|
|
64
|
+
for (const t of colorTokens.slice(0, CAP_COLOR)) {
|
|
65
|
+
const modes = t.modes
|
|
66
|
+
? Object.entries(t.modes)
|
|
67
|
+
.map(([m, v]) => `${m}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
|
68
|
+
.join(' | ')
|
|
69
|
+
: JSON.stringify(t.value);
|
|
70
|
+
lines.push(`- \`${t.name}\` — ${modes}`);
|
|
71
|
+
}
|
|
72
|
+
lines.push(``);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (textTokens.length) {
|
|
76
|
+
lines.push(`### Typography`);
|
|
77
|
+
lines.push(``);
|
|
78
|
+
for (const t of textTokens.slice(0, CAP_TYPOGRAPHY)) {
|
|
79
|
+
const v = t.value || Object.values(t.modes || {})[0];
|
|
80
|
+
lines.push(`- \`${t.name}\` — ${typeof v === 'object' ? JSON.stringify(v) : v}`);
|
|
81
|
+
}
|
|
82
|
+
lines.push(``);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (otherTokens.length) {
|
|
86
|
+
lines.push(`### Other`);
|
|
87
|
+
lines.push(``);
|
|
88
|
+
for (const t of otherTokens.slice(0, CAP_OTHER)) {
|
|
89
|
+
lines.push(`- \`${t.name}\` (${t.type})`);
|
|
90
|
+
}
|
|
91
|
+
lines.push(``);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── ## Components ───────────────────────────────────────────────────────────
|
|
95
|
+
lines.push(`## Components`);
|
|
96
|
+
lines.push(``);
|
|
97
|
+
const sets = comps.filter((c) => c.type === 'COMPONENT_SET');
|
|
98
|
+
const singles = comps.filter((c) => c.type === 'COMPONENT');
|
|
99
|
+
lines.push(
|
|
100
|
+
`Total: ${sets.length} component sets + ${singles.length} singleton components`
|
|
101
|
+
);
|
|
102
|
+
lines.push(``);
|
|
103
|
+
|
|
104
|
+
for (const c of sets) {
|
|
105
|
+
lines.push(`### ${c.name}`);
|
|
106
|
+
if (c.description) lines.push(`> ${c.description}`);
|
|
107
|
+
if (c.variants && c.variants.length) {
|
|
108
|
+
lines.push(`Variants (${c.variants.length}):`);
|
|
109
|
+
for (const v of c.variants.slice(0, CAP_VARIANTS)) lines.push(`- ${v}`);
|
|
110
|
+
if (c.variants.length > CAP_VARIANTS) {
|
|
111
|
+
lines.push(`- … +${c.variants.length - CAP_VARIANTS} more`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (c.props && c.props.length) {
|
|
115
|
+
lines.push(`Props:`);
|
|
116
|
+
for (const p of c.props) {
|
|
117
|
+
const opts = p.options ? ` [${p.options.join(', ')}]` : '';
|
|
118
|
+
lines.push(`- \`${p.name}\` (${p.type})${opts} — default: \`${p.default}\``);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
lines.push(``);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (singles.length) {
|
|
125
|
+
lines.push(`### Singleton components`);
|
|
126
|
+
lines.push(``);
|
|
127
|
+
for (const c of singles.slice(0, CAP_SINGLETONS)) {
|
|
128
|
+
lines.push(`- \`${c.name}\``);
|
|
129
|
+
}
|
|
130
|
+
lines.push(``);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── ## Widgets / Pages ──────────────────────────────────────────────────────
|
|
134
|
+
lines.push(`## Widgets / Pages`);
|
|
135
|
+
lines.push(``);
|
|
136
|
+
for (const w of wids.slice(0, CAP_WIDGETS)) {
|
|
137
|
+
lines.push(`- ${w.name} (\`${w.id}\`)`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return lines.join('\n');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = { renderDesignMd };
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Plan 31-03 — Path B of D-04 (three-path token extraction).
|
|
3
|
+
//
|
|
4
|
+
// Fixes spike 001's 0-tokens bug. The spike's digest.mjs extractTokensFromStyles
|
|
5
|
+
// (lines 96-132) looked up each /styles entry's `node_id` inside `file.document`
|
|
6
|
+
// and found nothing — because published-style SOURCE nodes are NOT serialized into
|
|
7
|
+
// the main document tree. They live in canvas frames that require a SEPARATE
|
|
8
|
+
// `/files/:key/nodes?ids=...` fetch. This module implements that missing second pass:
|
|
9
|
+
//
|
|
10
|
+
// step 1: read the /styles list (node_id + style_type + name) <- caller supplies
|
|
11
|
+
// step 2: GET /files/:key/nodes?ids=<comma-joined> to read real values <- injected fetcher
|
|
12
|
+
//
|
|
13
|
+
// Resolution priority within D-04: Variables > plugin sync > styles. Styles (this
|
|
14
|
+
// module) is the last-resort fallback for non-Enterprise, legacy-styles DSs.
|
|
15
|
+
//
|
|
16
|
+
// No direct network call lives here except inside the buildStylesResolver-bound
|
|
17
|
+
// fetcher; tests drive resolveStyleTokens fully offline via an injected fetchNodes.
|
|
18
|
+
|
|
19
|
+
// Chunk cap for /nodes?ids= requests. Figma limits URL length, so large style sets
|
|
20
|
+
// are split into batches of this size and the results merged.
|
|
21
|
+
const MAX_IDS_PER_REQUEST = 100;
|
|
22
|
+
|
|
23
|
+
const DEFAULT_API_BASE = 'https://api.figma.com/v1';
|
|
24
|
+
|
|
25
|
+
// rgb(0..1) channels → 2-hex; appends an alpha hex byte only when a < 1.
|
|
26
|
+
// Ported from spike 001 digest.mjs rgbToHex (lines 13-17) — keep value shape identical.
|
|
27
|
+
function rgbToHex({ r, g, b, a }) {
|
|
28
|
+
const to = (v) => Math.round((v || 0) * 255).toString(16).padStart(2, '0');
|
|
29
|
+
const hex = `#${to(r)}${to(g)}${to(b)}`;
|
|
30
|
+
return a !== undefined && a < 1 ? `${hex}${to(a)}` : hex;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Split an array into contiguous chunks of at most `size`.
|
|
34
|
+
function chunk(arr, size) {
|
|
35
|
+
const out = [];
|
|
36
|
+
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Figma's /nodes response wraps each node under `.document`. Tolerate both the
|
|
41
|
+
// wrapped shape ({ document: <node> }) and a bare node, so the resolver is robust
|
|
42
|
+
// to either the live API or a flattened fixture.
|
|
43
|
+
function unwrapNode(entry) {
|
|
44
|
+
if (!entry) return undefined;
|
|
45
|
+
return entry.document !== undefined ? entry.document : entry;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Resolve a single style's value from its source node, by style_type.
|
|
49
|
+
// Returns undefined when the node lacks the data for that type (style is then skipped).
|
|
50
|
+
function resolveValue(styleType, node) {
|
|
51
|
+
if (!node) return undefined;
|
|
52
|
+
if (styleType === 'FILL') {
|
|
53
|
+
const fill = node.fills && node.fills[0];
|
|
54
|
+
if (fill && fill.color) return rgbToHex({ ...fill.color, a: fill.opacity });
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
if (styleType === 'TEXT') {
|
|
58
|
+
const st = node.style;
|
|
59
|
+
if (!st) return undefined;
|
|
60
|
+
return {
|
|
61
|
+
family: st.fontFamily,
|
|
62
|
+
weight: st.fontWeight,
|
|
63
|
+
size: st.fontSize,
|
|
64
|
+
lineHeight: st.lineHeightPx,
|
|
65
|
+
letterSpacing: st.letterSpacing,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (styleType === 'EFFECT') {
|
|
69
|
+
const eff = node.effects && node.effects[0];
|
|
70
|
+
return eff !== undefined ? eff : undefined;
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Core two-step resolver (Path B). Pure transform over injected data — no network.
|
|
76
|
+
// stylesList: the /styles response body
|
|
77
|
+
// ({ meta: { styles: [{ node_id, style_type, name, description }] } })
|
|
78
|
+
// fetchNodes: async (ids: string[]) => /nodes response body
|
|
79
|
+
// ({ nodes: { <id>: { document: <node> } | <node> } })
|
|
80
|
+
// Returns Array<{ name, type:'FILL'|'TEXT'|'EFFECT', value, description }>.
|
|
81
|
+
// FILL → value = hex string (rgb→hex, alpha-aware)
|
|
82
|
+
// TEXT → value = { family, weight, size, lineHeight, letterSpacing }
|
|
83
|
+
// EFFECT → value = the first effect object
|
|
84
|
+
// Returns [] when stylesList has no styles (fetchNodes is NOT called), or when every
|
|
85
|
+
// node lookup misses. A style whose node_id is absent from /nodes is skipped (graceful).
|
|
86
|
+
async function resolveStyleTokens({ stylesList, fetchNodes }) {
|
|
87
|
+
const styles = (stylesList && stylesList.meta && stylesList.meta.styles) || [];
|
|
88
|
+
if (styles.length === 0) return [];
|
|
89
|
+
if (typeof fetchNodes !== 'function') {
|
|
90
|
+
throw new TypeError('resolveStyleTokens: fetchNodes must be a function');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Step 2: batch the node_ids and fetch their real source nodes, merging into one map.
|
|
94
|
+
const ids = styles.map((s) => s.node_id).filter((id) => id != null);
|
|
95
|
+
const nodeMap = {};
|
|
96
|
+
for (const idChunk of chunk(ids, MAX_IDS_PER_REQUEST)) {
|
|
97
|
+
const body = await fetchNodes(idChunk);
|
|
98
|
+
const nodes = (body && body.nodes) || {};
|
|
99
|
+
for (const id of idChunk) {
|
|
100
|
+
const node = unwrapNode(nodes[id]);
|
|
101
|
+
if (node !== undefined) nodeMap[id] = node;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Map each style onto its resolved value. Skip styles whose node missed or whose
|
|
106
|
+
// node lacked the data for its type.
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const s of styles) {
|
|
109
|
+
const node = nodeMap[s.node_id];
|
|
110
|
+
if (!node) continue;
|
|
111
|
+
const value = resolveValue(s.style_type, node);
|
|
112
|
+
if (value !== undefined) {
|
|
113
|
+
out.push({
|
|
114
|
+
name: s.name,
|
|
115
|
+
type: s.style_type,
|
|
116
|
+
value,
|
|
117
|
+
description: s.description || '',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Bind a resolver to a live (fileKey, token, fetchImpl, apiBase) so digest.cjs can
|
|
125
|
+
// inject Path B. Returns an async fn(file, styles) — exactly the `stylesResolver(file, styles)`
|
|
126
|
+
// seam shape digest.cjs (31-02) calls. It ignores `file` (the document tree never holds
|
|
127
|
+
// the source nodes — that is the spike bug) and resolves `styles` via a /nodes fetcher.
|
|
128
|
+
// 31-07's SKILL wires this for live runs. The token is sent ONLY as the X-Figma-Token
|
|
129
|
+
// header and is NEVER logged or persisted (D-10).
|
|
130
|
+
function buildStylesResolver({ fileKey, token, fetchImpl, apiBase } = {}) {
|
|
131
|
+
const base = apiBase || DEFAULT_API_BASE;
|
|
132
|
+
const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : undefined);
|
|
133
|
+
return async function stylesResolver(_file, styles) {
|
|
134
|
+
const fetchNodes = async (ids) => {
|
|
135
|
+
if (typeof doFetch !== 'function') {
|
|
136
|
+
throw new Error('buildStylesResolver: no fetch implementation available');
|
|
137
|
+
}
|
|
138
|
+
const url = `${base}/files/${fileKey}/nodes?ids=${ids.join(',')}`;
|
|
139
|
+
const res = await doFetch(url, { headers: { 'X-Figma-Token': token } });
|
|
140
|
+
if (!res.ok) throw new Error(`/nodes ${res.status}`);
|
|
141
|
+
return res.json();
|
|
142
|
+
};
|
|
143
|
+
return resolveStyleTokens({ stylesList: styles, fetchNodes });
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { resolveStyleTokens, buildStylesResolver, MAX_IDS_PER_REQUEST, rgbToHex };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Plan 31-02 — productionized from spike 001 digest.mjs walk() + summarizeWidgets().
|
|
4
|
+
*
|
|
5
|
+
* Node-tree walker with VARIANT ROLLUP (decision D-02, variant rollup default-on).
|
|
6
|
+
*
|
|
7
|
+
* The spike proved a naive walk inflates the component count ~16× (2,593 vs 167
|
|
8
|
+
* entries) because each COMPONENT_SET's variant children are counted as separate
|
|
9
|
+
* components. The fix — locked here as the non-optional default — is to SKIP the
|
|
10
|
+
* COMPONENT children of a COMPONENT_SET and record their names as a `variants[]`
|
|
11
|
+
* field on the parent set. A COMPONENT_SET with N variant children therefore
|
|
12
|
+
* yields exactly ONE component entry, not N (+1).
|
|
13
|
+
*
|
|
14
|
+
* Pure CommonJS, no external deps, no I/O, no network.
|
|
15
|
+
*
|
|
16
|
+
* Exports:
|
|
17
|
+
* walkDocument(node, ctx, parentIsSet) — low-level recursive helper (unit-testable)
|
|
18
|
+
* collectComponents(documentNode) — top-level entry over file.document
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Recursive tree walker. Mutates `ctx` in place.
|
|
23
|
+
*
|
|
24
|
+
* @param {object|null|undefined} node a Figma node (document/canvas/frame/component/…)
|
|
25
|
+
* @param {{components:Array, widgets:Array, depth:number}} ctx accumulator
|
|
26
|
+
* @param {boolean} [parentIsSet=false] true when the parent node is a COMPONENT_SET
|
|
27
|
+
*/
|
|
28
|
+
function walkDocument(node, ctx, parentIsSet = false) {
|
|
29
|
+
if (!node) return;
|
|
30
|
+
|
|
31
|
+
// Rollup core: a COMPONENT is only a standalone component when its parent is
|
|
32
|
+
// NOT a COMPONENT_SET. COMPONENT children of a set are variants — skipped here
|
|
33
|
+
// (they are recorded as variants[] on the parent set below).
|
|
34
|
+
const isStandaloneComponent = node.type === 'COMPONENT' && !parentIsSet;
|
|
35
|
+
|
|
36
|
+
if (node.type === 'COMPONENT_SET' || isStandaloneComponent) {
|
|
37
|
+
ctx.components.push({
|
|
38
|
+
id: node.id,
|
|
39
|
+
name: node.name,
|
|
40
|
+
type: node.type,
|
|
41
|
+
description: node.description || '',
|
|
42
|
+
// Variant names live on the set's children; standalone components have none.
|
|
43
|
+
variants:
|
|
44
|
+
node.type === 'COMPONENT_SET'
|
|
45
|
+
? (node.children || []).map((c) => c.name)
|
|
46
|
+
: undefined,
|
|
47
|
+
// componentPropertyDefinitions → flattened props. Figma suffixes prop keys
|
|
48
|
+
// with '#<id>' for uniqueness; strip it for the human-facing name.
|
|
49
|
+
props: node.componentPropertyDefinitions
|
|
50
|
+
? Object.entries(node.componentPropertyDefinitions).map(([k, v]) => ({
|
|
51
|
+
name: k.split('#')[0],
|
|
52
|
+
type: v.type,
|
|
53
|
+
default: v.defaultValue,
|
|
54
|
+
options: v.variantOptions,
|
|
55
|
+
}))
|
|
56
|
+
: undefined,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Top-level FRAMEs (depth 1 — direct children of a page/canvas) are widget /
|
|
61
|
+
// page candidates for downstream classification.
|
|
62
|
+
if (ctx.depth === 1 && node.type === 'FRAME') {
|
|
63
|
+
ctx.widgets.push({ id: node.id, name: node.name });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (node.children) {
|
|
67
|
+
ctx.depth++;
|
|
68
|
+
// Children of a COMPONENT_SET are variants — flag so they are not re-pushed.
|
|
69
|
+
const childParentIsSet = node.type === 'COMPONENT_SET';
|
|
70
|
+
for (const child of node.children) walkDocument(child, ctx, childParentIsSet);
|
|
71
|
+
ctx.depth--;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Collect components (with variant rollup) and top-level frames from a document.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} documentNode file.document — has .children = pages (CANVAS nodes)
|
|
79
|
+
* @returns {{components:Array, widgets:Array}}
|
|
80
|
+
* components: Array<{ id, name, type:'COMPONENT_SET'|'COMPONENT', description,
|
|
81
|
+
* variants?:string[], props?:Array<{name,type,default,options}> }>
|
|
82
|
+
* widgets: Array<{ id, name }> — top-level FRAMEs (depth 1)
|
|
83
|
+
*/
|
|
84
|
+
function collectComponents(documentNode) {
|
|
85
|
+
const ctx = { components: [], widgets: [], depth: 0 };
|
|
86
|
+
if (!documentNode || !documentNode.children) {
|
|
87
|
+
return { components: ctx.components, widgets: ctx.widgets };
|
|
88
|
+
}
|
|
89
|
+
// Pages (CANVAS) sit at depth 0; their children are depth 1 — that's where
|
|
90
|
+
// top-level frames become widget candidates. Mirror the spike's depth handling
|
|
91
|
+
// by entering each page's children at depth 1.
|
|
92
|
+
for (const page of documentNode.children) {
|
|
93
|
+
if (!page || !page.children) continue;
|
|
94
|
+
ctx.depth = 1;
|
|
95
|
+
for (const child of page.children) walkDocument(child, ctx, false);
|
|
96
|
+
}
|
|
97
|
+
return { components: ctx.components, widgets: ctx.widgets };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { walkDocument, collectComponents };
|