figma-console-mcp 1.40.2 → 1.40.3
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/README.md +3 -2
- package/dist/cloudflare/core/design-code-tools.js +545 -144
- package/dist/cloudflare/core/tokens-tools.js +1 -1
- package/dist/cloudflare/index.js +3 -3
- package/dist/core/design-code-tools.d.ts +82 -2
- package/dist/core/design-code-tools.d.ts.map +1 -1
- package/dist/core/design-code-tools.js +545 -144
- package/dist/core/design-code-tools.js.map +1 -1
- package/dist/core/tokens/schemas.d.ts +2 -2
- package/dist/core/tokens-tools.js +1 -1
- package/package.json +1 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* and generating platform-agnostic component documentation.
|
|
5
5
|
*/
|
|
6
6
|
import { z } from "zod";
|
|
7
|
-
import { extractFileKey } from "./figma-api.js";
|
|
7
|
+
import { extractFileKey, withTimeout } from "./figma-api.js";
|
|
8
8
|
import { createChildLogger } from "./logger.js";
|
|
9
9
|
import { EnrichmentService } from "./enrichment/index.js";
|
|
10
10
|
import { buildDesignHistory, DEFAULT_HISTORY_VERSIONS, MAX_HISTORY_VERSIONS, } from "./history/component-history.js";
|
|
@@ -107,6 +107,18 @@ export function chunkMarkdownByHeaders(markdown) {
|
|
|
107
107
|
* Clean a raw Figma variant name like "Type=Image, Size=12" into "Image / 12".
|
|
108
108
|
* Extracts just the values from "Key=Value" pairs, joined by " / ".
|
|
109
109
|
*/
|
|
110
|
+
/**
|
|
111
|
+
* How many levels of a component's tree the doc tool fetches and walks. The REST
|
|
112
|
+
* fetch counts from the requested node, so for a COMPONENT_SET one level is
|
|
113
|
+
* spent on the variants themselves. Anything deeper is cut off by the API — and
|
|
114
|
+
* REST returns a cut-off container as `children: []`, indistinguishable from an
|
|
115
|
+
* empty frame, so the walkers report when they reach this limit.
|
|
116
|
+
*/
|
|
117
|
+
export const DOC_TREE_DEPTH = 8;
|
|
118
|
+
/** Depth used when the deep fetch fails or times out — the pre-1.40.3 behavior */
|
|
119
|
+
const SHALLOW_FETCH_DEPTH = 4;
|
|
120
|
+
const DEEP_FETCH_TIMEOUT_MS = 45_000;
|
|
121
|
+
const BOOLEAN_LIKE_VALUE = /^(true|false|yes|no|on|off)$/i;
|
|
110
122
|
export function cleanVariantName(rawName) {
|
|
111
123
|
// Match Key=Value pairs separated by comma/space
|
|
112
124
|
const pairs = rawName.match(/(\w[\w\s]*)=([^,]+)/g);
|
|
@@ -114,7 +126,10 @@ export function cleanVariantName(rawName) {
|
|
|
114
126
|
return rawName;
|
|
115
127
|
const values = pairs.map((p) => {
|
|
116
128
|
const eqIdx = p.indexOf("=");
|
|
117
|
-
|
|
129
|
+
const value = p.slice(eqIdx + 1).trim();
|
|
130
|
+
// "False" is not a name. A boolean-like value only means something next to
|
|
131
|
+
// its property: "Is scrollable=False", "Hover / Is Selected=True".
|
|
132
|
+
return BOOLEAN_LIKE_VALUE.test(value) ? `${p.slice(0, eqIdx).trim()}=${value}` : value;
|
|
118
133
|
});
|
|
119
134
|
return values.join(" / ");
|
|
120
135
|
}
|
|
@@ -283,6 +298,31 @@ export function parseComponentDescription(description) {
|
|
|
283
298
|
result.overview = overviewLines.join(" ").trim();
|
|
284
299
|
return result;
|
|
285
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* id → token name. Two collections can each hold a variable with the SAME name
|
|
303
|
+
* ("Primitive/2x" in both Spacing and Radius), which is ambiguous to paste into
|
|
304
|
+
* code — those, and only those, are qualified with their collection. Every other
|
|
305
|
+
* token keeps the plain name people already map to code.
|
|
306
|
+
*/
|
|
307
|
+
export function buildVariableNameMap(variables, collections) {
|
|
308
|
+
const collectionNames = new Map();
|
|
309
|
+
if (Array.isArray(collections))
|
|
310
|
+
for (const c of collections)
|
|
311
|
+
if (c?.id && c?.name)
|
|
312
|
+
collectionNames.set(c.id, c.name);
|
|
313
|
+
const nameCounts = new Map();
|
|
314
|
+
for (const v of variables)
|
|
315
|
+
if (v?.name)
|
|
316
|
+
nameCounts.set(v.name, (nameCounts.get(v.name) ?? 0) + 1);
|
|
317
|
+
const out = new Map();
|
|
318
|
+
for (const v of variables) {
|
|
319
|
+
if (!v?.id || !v?.name)
|
|
320
|
+
continue;
|
|
321
|
+
const collection = collectionNames.get(v.variableCollectionId);
|
|
322
|
+
out.set(v.id, (nameCounts.get(v.name) ?? 0) > 1 && collection ? `${collection}/${v.name}` : v.name);
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
286
326
|
/** Strip Figma's internal "#123:4" id suffix from a component property name */
|
|
287
327
|
function stripPropertyIdSuffix(rawName) {
|
|
288
328
|
return rawName.replace(/#\d+:\d+$/, "").trim();
|
|
@@ -302,6 +342,50 @@ export function resolveMainComponentName(componentId, lookup = {}) {
|
|
|
302
342
|
return `${setName} (${cleanVariantName(comp.name)})`;
|
|
303
343
|
return comp.name;
|
|
304
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* Describe a visible paint as a table value. Solid paints are a hex; anything
|
|
347
|
+
* else is named rather than dropped — silence would read as "nothing there".
|
|
348
|
+
*/
|
|
349
|
+
function describePaint(paint) {
|
|
350
|
+
if (!paint || paint.visible === false)
|
|
351
|
+
return null;
|
|
352
|
+
if (paint.type === "SOLID") {
|
|
353
|
+
if (!paint.color)
|
|
354
|
+
return null;
|
|
355
|
+
return figmaRGBAToHex({ ...paint.color, a: paint.opacity ?? paint.color.a ?? 1 });
|
|
356
|
+
}
|
|
357
|
+
if (typeof paint.type === "string" && paint.type.startsWith("GRADIENT_")) {
|
|
358
|
+
const kind = paint.type.replace("GRADIENT_", "").toLowerCase();
|
|
359
|
+
const stops = Array.isArray(paint.gradientStops) ? paint.gradientStops : [];
|
|
360
|
+
const stopHexes = stops
|
|
361
|
+
.filter((st) => st?.color)
|
|
362
|
+
.map((st) => figmaRGBAToHex({ ...st.color, a: st.color.a ?? 1 }));
|
|
363
|
+
const detail = stopHexes.length > 0 ? `: ${stopHexes.join(" → ")}` : "";
|
|
364
|
+
return `${kind} gradient (${stops.length} stops${detail})`;
|
|
365
|
+
}
|
|
366
|
+
if (paint.type === "IMAGE")
|
|
367
|
+
return "image fill";
|
|
368
|
+
if (paint.type === "VIDEO")
|
|
369
|
+
return "video fill";
|
|
370
|
+
if (paint.type === "PATTERN")
|
|
371
|
+
return "pattern fill";
|
|
372
|
+
return typeof paint.type === "string" ? `${paint.type.toLowerCase()} paint` : null;
|
|
373
|
+
}
|
|
374
|
+
/** Describe a visible effect (shadow / blur) as a table value */
|
|
375
|
+
function describeEffect(effect) {
|
|
376
|
+
if (!effect || effect.visible === false || typeof effect.type !== "string")
|
|
377
|
+
return null;
|
|
378
|
+
if (effect.type === "DROP_SHADOW" || effect.type === "INNER_SHADOW") {
|
|
379
|
+
const color = effect.color ? figmaRGBAToHex({ ...effect.color, a: effect.color.a ?? 1 }) : "";
|
|
380
|
+
const x = effect.offset?.x ?? 0, y = effect.offset?.y ?? 0;
|
|
381
|
+
return `${effect.type === "DROP_SHADOW" ? "drop" : "inner"} shadow: x ${x} · y ${y} · blur ${effect.radius ?? 0} · spread ${effect.spread ?? 0}${color ? ` · ${color}` : ""}`;
|
|
382
|
+
}
|
|
383
|
+
if (effect.type === "LAYER_BLUR")
|
|
384
|
+
return `layer blur ${effect.radius ?? 0}px`;
|
|
385
|
+
if (effect.type === "BACKGROUND_BLUR")
|
|
386
|
+
return `background blur ${effect.radius ?? 0}px`;
|
|
387
|
+
return `${effect.type.toLowerCase().replace(/_/g, " ")} effect`;
|
|
388
|
+
}
|
|
305
389
|
/** Largest dimension (px) an instance-swap slot can have and still be treated as an icon */
|
|
306
390
|
const MAX_ICON_SLOT_SIZE = 64;
|
|
307
391
|
/**
|
|
@@ -337,10 +421,9 @@ function detectIconInstance(node, lookup) {
|
|
|
337
421
|
...(swapProperty ? { swapProperty } : {}),
|
|
338
422
|
};
|
|
339
423
|
}
|
|
340
|
-
/** True when the node has at least one visible solid
|
|
424
|
+
/** True when the node has at least one visible paint (solid, gradient, image…) */
|
|
341
425
|
function hasVisibleSolidFill(node) {
|
|
342
|
-
return Array.isArray(node?.fills)
|
|
343
|
-
&& node.fills.some((f) => f.type === "SOLID" && f.color && f.visible !== false);
|
|
426
|
+
return Array.isArray(node?.fills) && node.fills.some((f) => describePaint(f) !== null);
|
|
344
427
|
}
|
|
345
428
|
/**
|
|
346
429
|
* When a variant's root is transparent, some systems paint the surface with a
|
|
@@ -386,12 +469,13 @@ export function collectAllVariantData(node, varNameMap, lookup = {}) {
|
|
|
386
469
|
iconColors: [],
|
|
387
470
|
strokes: [],
|
|
388
471
|
textColors: [],
|
|
472
|
+
effects: [],
|
|
389
473
|
icons: [],
|
|
390
474
|
};
|
|
391
475
|
const backgroundLayer = findBackgroundLayer(variant);
|
|
392
476
|
if (backgroundLayer)
|
|
393
477
|
data.backgroundLayer = backgroundLayer.name || "background layer";
|
|
394
|
-
walkVariantNode(variant, data, { varNameMap, lookup, backgroundLayer }, 0,
|
|
478
|
+
walkVariantNode(variant, data, { varNameMap, lookup, backgroundLayer }, 0, DOC_TREE_DEPTH, null);
|
|
395
479
|
variants.push(data);
|
|
396
480
|
}
|
|
397
481
|
return variants;
|
|
@@ -399,26 +483,33 @@ export function collectAllVariantData(node, varNameMap, lookup = {}) {
|
|
|
399
483
|
/** Walk a single variant node tree to collect colors and icons */
|
|
400
484
|
function walkVariantNode(node, data, ctx, depth, maxDepth,
|
|
401
485
|
/** Label of the icon instance we're inside of, or null when outside any icon */
|
|
402
|
-
insideIcon
|
|
486
|
+
insideIcon,
|
|
487
|
+
/** An ancestor is hidden, so nothing beneath it paints either */
|
|
488
|
+
ancestorHidden = false) {
|
|
403
489
|
if (depth > maxDepth)
|
|
404
490
|
return;
|
|
405
491
|
const isText = node.type === "TEXT";
|
|
492
|
+
// Hidden layers are COLLECTED, not skipped: a hidden focus ring is real design
|
|
493
|
+
// intent. They are labeled so a reader can tell it from what actually renders.
|
|
494
|
+
const hidden = ancestorHidden || node.visible === false;
|
|
406
495
|
// Icons are reported once, at the outermost instance — everything beneath it
|
|
407
496
|
// (vectors, nested instances) is that icon's artwork, not a separate icon.
|
|
408
497
|
if (insideIcon === null) {
|
|
409
498
|
const icon = detectIconInstance(node, ctx.lookup);
|
|
410
499
|
if (icon) {
|
|
411
|
-
data.icons.push(icon);
|
|
500
|
+
data.icons.push(hidden ? { ...icon, hidden: true } : icon);
|
|
412
501
|
insideIcon = icon.layerName || icon.name;
|
|
413
502
|
}
|
|
414
503
|
}
|
|
415
|
-
const toEntry = (paint) => {
|
|
504
|
+
const toEntry = (paint, value) => {
|
|
416
505
|
const varId = paint.boundVariables?.color?.id;
|
|
417
506
|
return {
|
|
418
|
-
hex:
|
|
419
|
-
|
|
507
|
+
hex: value,
|
|
508
|
+
// The variant root's own name is "Size=lg, State=hover" — not a layer name
|
|
509
|
+
nodeName: depth === 0 ? "" : node.name || "",
|
|
420
510
|
variableId: varId,
|
|
421
511
|
variableName: varId ? ctx.varNameMap.get(varId) : undefined,
|
|
512
|
+
...(hidden ? { hidden: true } : {}),
|
|
422
513
|
};
|
|
423
514
|
};
|
|
424
515
|
// Collect fills. Only the variant root (or its full-bleed stand-in) is a
|
|
@@ -426,8 +517,9 @@ insideIcon) {
|
|
|
426
517
|
// transparent variant gets documented with its icon's color as the surface.
|
|
427
518
|
if (node.fills && Array.isArray(node.fills)) {
|
|
428
519
|
for (const fill of node.fills) {
|
|
429
|
-
|
|
430
|
-
|
|
520
|
+
const value = describePaint(fill);
|
|
521
|
+
if (value) {
|
|
522
|
+
const entry = toEntry(fill, value);
|
|
431
523
|
if (isText) {
|
|
432
524
|
data.textColors.push(entry);
|
|
433
525
|
}
|
|
@@ -446,8 +538,9 @@ insideIcon) {
|
|
|
446
538
|
// Collect strokes
|
|
447
539
|
if (node.strokes && Array.isArray(node.strokes)) {
|
|
448
540
|
for (const stroke of node.strokes) {
|
|
449
|
-
|
|
450
|
-
|
|
541
|
+
const value = describePaint(stroke);
|
|
542
|
+
if (value) {
|
|
543
|
+
const entry = toEntry(stroke, value);
|
|
451
544
|
if (insideIcon !== null) {
|
|
452
545
|
data.iconColors.push({ ...entry, iconLabel: insideIcon });
|
|
453
546
|
}
|
|
@@ -457,24 +550,44 @@ insideIcon) {
|
|
|
457
550
|
}
|
|
458
551
|
}
|
|
459
552
|
}
|
|
553
|
+
// Effects and opacity — outside icon artwork, which has its own internals
|
|
554
|
+
if (insideIcon === null || depth === 0) {
|
|
555
|
+
if (Array.isArray(node.effects)) {
|
|
556
|
+
for (const effect of node.effects) {
|
|
557
|
+
const value = describeEffect(effect);
|
|
558
|
+
if (value)
|
|
559
|
+
data.effects.push(toEntry(effect, value));
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (typeof node.opacity === "number" && node.opacity < 1) {
|
|
563
|
+
data.effects.push({
|
|
564
|
+
...toEntry({}, `opacity ${Math.round(node.opacity * 100)}%`),
|
|
565
|
+
variableId: node.boundVariables?.opacity?.id,
|
|
566
|
+
variableName: node.boundVariables?.opacity?.id ? ctx.varNameMap.get(node.boundVariables.opacity.id) : undefined,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
460
570
|
// Recurse into children
|
|
461
571
|
if (node.children && Array.isArray(node.children)) {
|
|
462
572
|
for (const child of node.children) {
|
|
463
|
-
walkVariantNode(child, data, ctx, depth + 1, maxDepth, insideIcon);
|
|
573
|
+
walkVariantNode(child, data, ctx, depth + 1, maxDepth, insideIcon, hidden);
|
|
464
574
|
}
|
|
465
575
|
}
|
|
466
576
|
}
|
|
467
577
|
/**
|
|
468
578
|
* Collect typography data from all text nodes in a component tree.
|
|
469
579
|
*/
|
|
470
|
-
export function collectTypographyData(node, depth = 0, maxDepth =
|
|
580
|
+
export function collectTypographyData(node, depth = 0, maxDepth = DOC_TREE_DEPTH, ancestorHidden = false) {
|
|
471
581
|
const results = [];
|
|
472
582
|
if (depth > maxDepth)
|
|
473
583
|
return results;
|
|
474
|
-
//
|
|
584
|
+
// A COMPONENT_SET resolves to its default (first) variant HERE ONLY. Anything
|
|
585
|
+
// documenting a set must use collectTypographyAcrossVariants — variants
|
|
586
|
+
// routinely differ in weight (a selected tab is SemiBold, the rest Regular).
|
|
475
587
|
if (node.type === "COMPONENT_SET" && node.children?.length > 0 && depth === 0) {
|
|
476
588
|
return collectTypographyData(node.children[0], 0, maxDepth);
|
|
477
589
|
}
|
|
590
|
+
const hidden = ancestorHidden || node.visible === false;
|
|
478
591
|
if (node.type === "TEXT" && node.style) {
|
|
479
592
|
const s = node.style;
|
|
480
593
|
const weightNames = {
|
|
@@ -489,20 +602,101 @@ export function collectTypographyData(node, depth = 0, maxDepth = 5) {
|
|
|
489
602
|
fontSize: s.fontSize || 14,
|
|
490
603
|
lineHeight: s.lineHeightPx || s.fontSize || 14,
|
|
491
604
|
letterSpacing: s.letterSpacing || 0,
|
|
605
|
+
...(hidden ? { hidden: true } : {}),
|
|
606
|
+
// REST: characterStyleOverrides maps characters → styleOverrideTable ids; any
|
|
607
|
+
// non-zero entry means part of the text departs from `style`
|
|
608
|
+
...(Array.isArray(node.characterStyleOverrides) && node.characterStyleOverrides.some((id) => id !== 0)
|
|
609
|
+
? { mixed: true } : {}),
|
|
492
610
|
});
|
|
493
611
|
}
|
|
494
612
|
if (node.children && Array.isArray(node.children)) {
|
|
495
613
|
for (const child of node.children) {
|
|
496
|
-
results.push(...collectTypographyData(child, depth + 1, maxDepth));
|
|
614
|
+
results.push(...collectTypographyData(child, depth + 1, maxDepth, hidden));
|
|
497
615
|
}
|
|
498
616
|
}
|
|
499
617
|
return results;
|
|
500
618
|
}
|
|
619
|
+
// `hidden` and `mixed` belong in the signature: a label shown in one variant and
|
|
620
|
+
// hidden in another is NOT "the same in all variants".
|
|
621
|
+
const typographySignature = (ts) => `${ts.fontFamily}|${ts.fontWeight}|${ts.fontSize}|${ts.lineHeight}|${ts.letterSpacing}|${ts.hidden ? "hidden" : ""}|${ts.mixed ? "mixed" : ""}`;
|
|
622
|
+
/**
|
|
623
|
+
* Typography for EVERY variant of a set. Per text element: one row when all
|
|
624
|
+
* variants agree, otherwise one row per distinct style, scoped by the variant
|
|
625
|
+
* property that drives it ("Is Selected=True") or, failing that, by variant name.
|
|
626
|
+
*/
|
|
627
|
+
export function collectTypographyAcrossVariants(setNode) {
|
|
628
|
+
const variants = Array.isArray(setNode?.children) ? setNode.children : [];
|
|
629
|
+
const perVariant = variants.map((v) => {
|
|
630
|
+
// Same-named text layers within one variant are told apart by order
|
|
631
|
+
const seen = new Map();
|
|
632
|
+
const byElement = new Map();
|
|
633
|
+
for (const ts of collectTypographyData(v)) {
|
|
634
|
+
const n = (seen.get(ts.nodeName) ?? 0) + 1;
|
|
635
|
+
seen.set(ts.nodeName, n);
|
|
636
|
+
byElement.set(n === 1 ? ts.nodeName : `${ts.nodeName} #${n}`, ts);
|
|
637
|
+
}
|
|
638
|
+
return { name: cleanVariantName(v.name || "Unknown"), props: parseVariantProperties(v.name || ""), byElement };
|
|
639
|
+
});
|
|
640
|
+
const elements = [];
|
|
641
|
+
for (const pv of perVariant)
|
|
642
|
+
for (const el of pv.byElement.keys())
|
|
643
|
+
if (!elements.includes(el))
|
|
644
|
+
elements.push(el);
|
|
645
|
+
const rows = [];
|
|
646
|
+
for (const el of elements) {
|
|
647
|
+
const having = perVariant.filter((pv) => pv.byElement.has(el));
|
|
648
|
+
const distinct = new Map();
|
|
649
|
+
for (const pv of having) {
|
|
650
|
+
const style = pv.byElement.get(el);
|
|
651
|
+
const sig = typographySignature(style);
|
|
652
|
+
if (!distinct.has(sig))
|
|
653
|
+
distinct.set(sig, { style, names: [] });
|
|
654
|
+
distinct.get(sig).names.push(pv.name);
|
|
655
|
+
}
|
|
656
|
+
if (distinct.size === 1) {
|
|
657
|
+
const only = [...distinct.values()][0];
|
|
658
|
+
// Present in only some variants? Say which, rather than implying all.
|
|
659
|
+
const scope = having.length === perVariant.length ? "" : scopeLabel(only.names, perVariant.length);
|
|
660
|
+
rows.push({ style: { ...only.style, nodeName: el }, scope });
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
const explanation = explainByVariantProperty(having.map((pv) => ({ props: pv.props, display: typographySignature(pv.byElement.get(el)) })));
|
|
664
|
+
for (const [sig, group] of distinct) {
|
|
665
|
+
const propValue = explanation?.groups.filter(([, display]) => display === sig).map(([pv]) => pv);
|
|
666
|
+
const scope = explanation && propValue && propValue.length > 0
|
|
667
|
+
? `${explanation.property}=${propValue.join(" | ")}`
|
|
668
|
+
: scopeLabel(group.names, perVariant.length);
|
|
669
|
+
rows.push({ style: { ...group.style, nodeName: el }, scope });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// Same-named layers ("Label", "Label #2", "Label #3" — three tabs in a tab list)
|
|
673
|
+
// that share a style and a scope are one fact, not three rows.
|
|
674
|
+
const merged = [];
|
|
675
|
+
const seenRows = new Set();
|
|
676
|
+
for (const row of rows) {
|
|
677
|
+
const baseName = row.style.nodeName.replace(/ #\d+$/, "");
|
|
678
|
+
const key = `${baseName}|${typographySignature(row.style)}|${row.scope}|${row.style.hidden ? "h" : ""}`;
|
|
679
|
+
if (seenRows.has(key))
|
|
680
|
+
continue;
|
|
681
|
+
seenRows.add(key);
|
|
682
|
+
// Text layers are often auto-named after their content — keep the table readable
|
|
683
|
+
const label = baseName.length > 40 ? `${baseName.slice(0, 40)}…` : baseName;
|
|
684
|
+
merged.push({ ...row, style: { ...row.style, nodeName: label } });
|
|
685
|
+
}
|
|
686
|
+
return merged;
|
|
687
|
+
}
|
|
688
|
+
/** "Primary / Default, Primary / Hover, +3 more" — enough to identify, short enough to read */
|
|
689
|
+
function scopeLabel(names, total) {
|
|
690
|
+
if (names.length === total)
|
|
691
|
+
return "";
|
|
692
|
+
const shown = names.slice(0, 3).join(", ");
|
|
693
|
+
return names.length > 3 ? `${shown}, +${names.length - 3} more` : shown;
|
|
694
|
+
}
|
|
501
695
|
/**
|
|
502
696
|
* Build an anatomy tree representation from a Figma node structure.
|
|
503
697
|
* Returns a formatted string showing the component's nested structure.
|
|
504
698
|
*/
|
|
505
|
-
export function buildAnatomyTree(node, depth = 0, maxDepth =
|
|
699
|
+
export function buildAnatomyTree(node, depth = 0, maxDepth = DOC_TREE_DEPTH) {
|
|
506
700
|
if (depth > maxDepth)
|
|
507
701
|
return "";
|
|
508
702
|
// For COMPONENT_SET, pick the variant with the deepest children tree for the richest anatomy
|
|
@@ -572,10 +766,13 @@ function buildAnatomyLines(node, lines, prefix, isLast, depth, maxDepth) {
|
|
|
572
766
|
if (parts.length > 0)
|
|
573
767
|
sizingInfo = ` [${parts.join(", ")}]`;
|
|
574
768
|
}
|
|
575
|
-
|
|
769
|
+
// Hidden layers stay in the tree, marked: the color table names them (a hidden
|
|
770
|
+
// focus ring is real design intent), so the tree must not pretend they don't exist.
|
|
771
|
+
const hiddenHint = node.visible === false ? " (hidden)" : "";
|
|
772
|
+
lines.push(`${prefix}${connector}${label}${typeHint}${hiddenHint}${layoutInfo}${sizingInfo}`);
|
|
576
773
|
// Recurse into children
|
|
577
774
|
if (node.children && Array.isArray(node.children)) {
|
|
578
|
-
const visibleChildren = node.children
|
|
775
|
+
const visibleChildren = node.children;
|
|
579
776
|
for (let i = 0; i < visibleChildren.length; i++) {
|
|
580
777
|
const isChildLast = i === visibleChildren.length - 1;
|
|
581
778
|
buildAnatomyLines(visibleChildren[i], lines, prefix + childPrefix, isChildLast, depth + 1, maxDepth);
|
|
@@ -587,6 +784,7 @@ function buildAnatomyLines(node, lines, prefix, isLast, depth, maxDepth) {
|
|
|
587
784
|
*/
|
|
588
785
|
function collectSpacingTokens(node, varNameMap = new Map()) {
|
|
589
786
|
const tokens = [];
|
|
787
|
+
const autoLayout = !!node.layoutMode && node.layoutMode !== "NONE";
|
|
590
788
|
const boundVars = node.boundVariables || {};
|
|
591
789
|
const spacingProps = [
|
|
592
790
|
{ key: "paddingTop", label: "Padding top" },
|
|
@@ -598,7 +796,16 @@ function collectSpacingTokens(node, varNameMap = new Map()) {
|
|
|
598
796
|
{ key: "strokeWeight", label: "Border width" },
|
|
599
797
|
];
|
|
600
798
|
for (const { key, label } of spacingProps) {
|
|
601
|
-
|
|
799
|
+
let value = node[key];
|
|
800
|
+
// Same rules as the cross-variant table: a border width only where a border
|
|
801
|
+
// is painted (and per-side weights win); zero padding is reported, not omitted.
|
|
802
|
+
if (key === "strokeWeight")
|
|
803
|
+
value = hasVisibleStroke(node) ? describeStrokeWeight(node) : undefined;
|
|
804
|
+
else if ((key.startsWith("padding") || key === "itemSpacing") && autoLayout)
|
|
805
|
+
value = value ?? 0;
|
|
806
|
+
else if (key === "cornerRadius" && (value === undefined || value === null) && Array.isArray(node.rectangleCornerRadii)) {
|
|
807
|
+
value = node.rectangleCornerRadii.map((r) => `${r}px`).join(" / ");
|
|
808
|
+
}
|
|
602
809
|
if (value !== undefined && value !== null) {
|
|
603
810
|
const varBinding = boundVars[key];
|
|
604
811
|
// Bound spacing variables expose their id as boundVariables[key].id.
|
|
@@ -615,6 +822,35 @@ function collectSpacingTokens(node, varNameMap = new Map()) {
|
|
|
615
822
|
}
|
|
616
823
|
return tokens;
|
|
617
824
|
}
|
|
825
|
+
/** True when the node paints at least one stroke */
|
|
826
|
+
function hasVisibleStroke(node) {
|
|
827
|
+
return Array.isArray(node?.strokes) && node.strokes.some((st) => describePaint(st) !== null);
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* The border width that actually RENDERS. With per-side weights (underlines,
|
|
831
|
+
* dividers, accent bars) the scalar `strokeWeight` is a leftover from before the
|
|
832
|
+
* override and appears nowhere in the component — `individualStrokeWeights` wins.
|
|
833
|
+
*/
|
|
834
|
+
export function describeStrokeWeight(node) {
|
|
835
|
+
if (!hasVisibleStroke(node))
|
|
836
|
+
return "none";
|
|
837
|
+
const sides = node.individualStrokeWeights;
|
|
838
|
+
if (sides && typeof sides === "object") {
|
|
839
|
+
const order = [
|
|
840
|
+
["top", sides.top ?? 0], ["right", sides.right ?? 0],
|
|
841
|
+
["bottom", sides.bottom ?? 0], ["left", sides.left ?? 0],
|
|
842
|
+
];
|
|
843
|
+
const weights = order.map(([, w]) => w);
|
|
844
|
+
if (weights.every((w) => w === weights[0]))
|
|
845
|
+
return weights[0];
|
|
846
|
+
const painted = order.filter(([, w]) => w > 0);
|
|
847
|
+
// "4px bottom" reads better than "0 / 0 / 4 / 0" when few sides are in play
|
|
848
|
+
if (painted.length <= 2)
|
|
849
|
+
return painted.map(([side, w]) => `${w}px ${side}`).join(", ");
|
|
850
|
+
return `${weights.map((w) => `${w}px`).join(" / ")} (top / right / bottom / left)`;
|
|
851
|
+
}
|
|
852
|
+
return node.strokeWeight ?? 0;
|
|
853
|
+
}
|
|
618
854
|
/** Parse "Size=lg, State=hover" into ordered [property, value] pairs */
|
|
619
855
|
export function parseVariantProperties(rawName) {
|
|
620
856
|
const pairs = [];
|
|
@@ -626,6 +862,38 @@ export function parseVariantProperties(rawName) {
|
|
|
626
862
|
}
|
|
627
863
|
return pairs;
|
|
628
864
|
}
|
|
865
|
+
/**
|
|
866
|
+
* Find ONE variant property that fully accounts for how a value differs across
|
|
867
|
+
* variants: grouping by that property leaves a single value per group.
|
|
868
|
+
*
|
|
869
|
+
* The explanation must be non-vacuous — with one variant per group any values
|
|
870
|
+
* at all would "fit" (single-property sets), so that doesn't count.
|
|
871
|
+
* Shared by spacing, typography and anatomy so they explain variation alike.
|
|
872
|
+
*/
|
|
873
|
+
export function explainByVariantProperty(samples) {
|
|
874
|
+
if (samples.length < 2)
|
|
875
|
+
return null;
|
|
876
|
+
for (const [propName] of samples[0].props) {
|
|
877
|
+
const groups = new Map();
|
|
878
|
+
let complete = true;
|
|
879
|
+
for (const x of samples) {
|
|
880
|
+
const propValue = x.props.find(([n]) => n === propName)?.[1];
|
|
881
|
+
if (propValue === undefined) {
|
|
882
|
+
complete = false;
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
if (!groups.has(propValue))
|
|
886
|
+
groups.set(propValue, new Set());
|
|
887
|
+
groups.get(propValue).add(x.display);
|
|
888
|
+
}
|
|
889
|
+
if (!complete)
|
|
890
|
+
continue;
|
|
891
|
+
if (groups.size > 1 && groups.size < samples.length && [...groups.values()].every((vals) => vals.size === 1)) {
|
|
892
|
+
return { property: propName, groups: [...groups.entries()].map(([pv, vals]) => [pv, [...vals][0]]) };
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
629
897
|
const SPACING_COMPARISON_PROPS = [
|
|
630
898
|
{ key: "paddingTop", label: "Padding top" },
|
|
631
899
|
{ key: "paddingRight", label: "Padding right" },
|
|
@@ -662,9 +930,17 @@ export function collectSpacingAcrossVariants(setNode, varNameMap = new Map()) {
|
|
|
662
930
|
if (variants.length === 0)
|
|
663
931
|
return result;
|
|
664
932
|
for (const { key, label } of SPACING_COMPARISON_PROPS) {
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
|
|
933
|
+
const isPadding = key.startsWith("padding") || key === "itemSpacing";
|
|
934
|
+
const isSet = (v) => (key === "strokeWeight"
|
|
935
|
+
// A border width only means something where a border is painted. REST
|
|
936
|
+
// reports strokeWeight (default 1) on every node, stroked or not.
|
|
937
|
+
? hasVisibleStroke(v)
|
|
938
|
+
: (v[key] !== undefined && v[key] !== null)
|
|
939
|
+
// Per-corner rounding: REST drops the scalar and sends only the 4-tuple
|
|
940
|
+
|| (key === "cornerRadius" && Array.isArray(v.rectangleCornerRadii))
|
|
941
|
+
// Auto-layout: REST omits zero-valued padding/gap, but 0 is a real,
|
|
942
|
+
// reportable value — leaving it out is indistinguishable from "unknown"
|
|
943
|
+
|| (isPadding && !!v.layoutMode && v.layoutMode !== "NONE"));
|
|
668
944
|
if (!variants.some(isSet))
|
|
669
945
|
continue;
|
|
670
946
|
const samples = variants.map((v) => {
|
|
@@ -673,6 +949,8 @@ export function collectSpacingAcrossVariants(setNode, varNameMap = new Map()) {
|
|
|
673
949
|
if (key === "cornerRadius" && (v[key] === undefined || v[key] === null) && Array.isArray(v.rectangleCornerRadii)) {
|
|
674
950
|
value = v.rectangleCornerRadii.map((r) => `${r}px`).join(" / ");
|
|
675
951
|
}
|
|
952
|
+
if (key === "strokeWeight")
|
|
953
|
+
value = describeStrokeWeight(v);
|
|
676
954
|
const varId = typeof v.boundVariables?.[key]?.id === "string"
|
|
677
955
|
? v.boundVariables[key].id
|
|
678
956
|
: undefined;
|
|
@@ -714,31 +992,20 @@ export function collectSpacingAcrossVariants(setNode, varNameMap = new Map()) {
|
|
|
714
992
|
: formatSpacingValue(x.value);
|
|
715
993
|
}
|
|
716
994
|
// Is the variation fully explained by ONE variant property (e.g. Size)?
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
const groups = new Map();
|
|
721
|
-
for (const x of samples) {
|
|
722
|
-
const propValue = x.props.find(([n]) => n === propName)?.[1];
|
|
723
|
-
if (propValue === undefined) {
|
|
724
|
-
groups.clear();
|
|
725
|
-
break;
|
|
726
|
-
}
|
|
727
|
-
if (!groups.has(propValue))
|
|
728
|
-
groups.set(propValue, new Set());
|
|
729
|
-
groups.get(propValue).add(x.display);
|
|
730
|
-
}
|
|
731
|
-
// groups.size < samples.length: with one variant per group the "explanation"
|
|
732
|
-
// is vacuous — any values at all would satisfy it (single-property sets).
|
|
733
|
-
if (groups.size > 1 && groups.size < samples.length && [...groups.values()].every((vals) => vals.size === 1)) {
|
|
734
|
-
explainedBy = propName;
|
|
735
|
-
explainedGroups = [...groups.entries()].map(([pv, vals]) => [pv, [...vals][0]]);
|
|
736
|
-
break;
|
|
737
|
-
}
|
|
738
|
-
}
|
|
995
|
+
const explanation = explainByVariantProperty(samples);
|
|
996
|
+
const explainedBy = explanation?.property ?? null;
|
|
997
|
+
const explainedGroups = explanation?.groups ?? [];
|
|
739
998
|
let valueCell;
|
|
740
999
|
if (explainedBy) {
|
|
741
|
-
|
|
1000
|
+
// Property values that land on the same result read better together:
|
|
1001
|
+
// "Active, Default, Hover 2px bottom · Focus 2px"
|
|
1002
|
+
const byResult = new Map();
|
|
1003
|
+
for (const [pv, val] of explainedGroups) {
|
|
1004
|
+
if (!byResult.has(val))
|
|
1005
|
+
byResult.set(val, []);
|
|
1006
|
+
byResult.get(val).push(pv);
|
|
1007
|
+
}
|
|
1008
|
+
valueCell = `varies by **${explainedBy}**: ${[...byResult.entries()].map(([val, pvs]) => `${pvs.join(", ")} ${val}`).join(" · ")}`;
|
|
742
1009
|
}
|
|
743
1010
|
else {
|
|
744
1011
|
// No pattern — list each distinct value, most common first, naming the rare ones
|
|
@@ -1691,19 +1958,19 @@ async function detectAtomicLevel(api, fileKey, nodeId, setNodeId, _componentMeta
|
|
|
1691
1958
|
return null;
|
|
1692
1959
|
}
|
|
1693
1960
|
}
|
|
1694
|
-
function generateFrontmatter(componentName, description, node, componentMeta, fileUrl, codeInfo, canonicalSource, level,
|
|
1961
|
+
export function generateFrontmatter(componentName, description, node, componentMeta, fileUrl, codeInfo, canonicalSource, level,
|
|
1695
1962
|
/**
|
|
1696
1963
|
* Newest Figma version that touched this component, when design history was
|
|
1697
1964
|
* pulled. Emitted as its own `figmaVersion` field rather than folded into
|
|
1698
1965
|
* `version` — that one is the code-side semver and means something different.
|
|
1699
1966
|
*/
|
|
1700
1967
|
latestDesignVersion) {
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
const version = codeInfo?.changelog?.[0]?.version ||
|
|
1968
|
+
// Only state what is actually known. "stable" / "1.0.0" used to be emitted for
|
|
1969
|
+
// every component and read as extracted facts.
|
|
1970
|
+
const status = componentMeta?.description?.toLowerCase().includes("deprecated")
|
|
1971
|
+
? "deprecated"
|
|
1972
|
+
: codeInfo?.changelog?.[0] ? "stable" : null;
|
|
1973
|
+
const version = codeInfo?.changelog?.[0]?.version || null;
|
|
1707
1974
|
const tags = [componentName.toLowerCase()];
|
|
1708
1975
|
if (level)
|
|
1709
1976
|
tags.push(level);
|
|
@@ -1714,9 +1981,9 @@ latestDesignVersion) {
|
|
|
1714
1981
|
const lines = [
|
|
1715
1982
|
"---",
|
|
1716
1983
|
`title: ${componentName}`,
|
|
1717
|
-
`description: ${((description.split(/\n\s*\n|\n#{1,6}\s|\n\*\*/)[0] || description).replace(/\n/g, " ").replace(/\s+/g, " ").trim().split(/(?<=[.!?])\s+/)[0]
|
|
1718
|
-
`status: ${status}
|
|
1719
|
-
`version: ${version}
|
|
1984
|
+
...((((description.split(/\n\s*\n|\n#{1,6}\s|\n\*\*/)[0] || description).replace(/\n/g, " ").replace(/\s+/g, " ").trim().split(/(?<=[.!?])\s+/)[0])) ? [`description: ${((description.split(/\n\s*\n|\n#{1,6}\s|\n\*\*/)[0] || description).replace(/\n/g, " ").replace(/\s+/g, " ").trim().split(/(?<=[.!?])\s+/)[0])}`] : []),
|
|
1985
|
+
...(status ? [`status: ${status}`] : []),
|
|
1986
|
+
...(version ? [`version: ${version}`] : []),
|
|
1720
1987
|
`category: components`,
|
|
1721
1988
|
...(level ? [`level: ${level}`] : []),
|
|
1722
1989
|
`tags: [${tags.join(", ")}]`,
|
|
@@ -1748,7 +2015,7 @@ latestDesignVersion) {
|
|
|
1748
2015
|
lines.push("---");
|
|
1749
2016
|
return lines.join("\n");
|
|
1750
2017
|
}
|
|
1751
|
-
function generateOverviewSection(componentName, description, fileUrl, parsedDesc, codeInfo) {
|
|
2018
|
+
export function generateOverviewSection(componentName, description, fileUrl, parsedDesc, codeInfo) {
|
|
1752
2019
|
const lines = [
|
|
1753
2020
|
`# ${componentName}`,
|
|
1754
2021
|
"",
|
|
@@ -1768,9 +2035,13 @@ function generateOverviewSection(componentName, description, fileUrl, parsedDesc
|
|
|
1768
2035
|
lines.push("## Overview");
|
|
1769
2036
|
lines.push("");
|
|
1770
2037
|
// Use parsed overview or fall back to raw description
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
2038
|
+
// No filler: libraries that deliberately keep descriptions empty would get a
|
|
2039
|
+
// sentence carrying no information on every page. Nothing says more than that.
|
|
2040
|
+
const overviewText = parsedDesc.overview || description?.split("\n")[0] || "";
|
|
2041
|
+
if (overviewText.trim()) {
|
|
2042
|
+
lines.push(overviewText);
|
|
2043
|
+
lines.push("");
|
|
2044
|
+
}
|
|
1774
2045
|
// Base component attribution
|
|
1775
2046
|
if (codeInfo?.baseComponent) {
|
|
1776
2047
|
const baseLink = codeInfo.baseComponent.url
|
|
@@ -1805,10 +2076,19 @@ function generateOverviewSection(componentName, description, fileUrl, parsedDesc
|
|
|
1805
2076
|
return lines.join("\n");
|
|
1806
2077
|
}
|
|
1807
2078
|
/** Icon cell text: the icon in the slot, flagged when it's only an instance-swap default */
|
|
1808
|
-
function formatIconCell(
|
|
1809
|
-
if (!
|
|
2079
|
+
function formatIconCell(icons) {
|
|
2080
|
+
if (!icons || icons.length === 0)
|
|
1810
2081
|
return "—";
|
|
1811
|
-
|
|
2082
|
+
// A chip with a leading AND a trailing icon has two — show both
|
|
2083
|
+
return icons.map(formatOneIcon).join("; ");
|
|
2084
|
+
}
|
|
2085
|
+
function formatOneIcon(icon) {
|
|
2086
|
+
const notes = [];
|
|
2087
|
+
if (icon.swapProperty)
|
|
2088
|
+
notes.push(`default — swappable via **${icon.swapProperty}**`);
|
|
2089
|
+
if (icon.hidden)
|
|
2090
|
+
notes.push("hidden in this variant");
|
|
2091
|
+
return notes.length > 0 ? `${icon.name} _(${notes.join("; ")})_` : icon.name;
|
|
1812
2092
|
}
|
|
1813
2093
|
export function generateStatesAndVariantsSection(node, variantData, lookup = {}) {
|
|
1814
2094
|
const props = node.componentPropertyDefinitions;
|
|
@@ -1819,6 +2099,7 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
|
|
|
1819
2099
|
const booleans = [];
|
|
1820
2100
|
const textProps = [];
|
|
1821
2101
|
const instanceSwaps = [];
|
|
2102
|
+
const slots = [];
|
|
1822
2103
|
for (const [rawName, def] of Object.entries(props)) {
|
|
1823
2104
|
// Strip Figma internal ID suffixes like "#17100:0" from property names
|
|
1824
2105
|
const name = stripPropertyIdSuffix(rawName);
|
|
@@ -1843,6 +2124,14 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
|
|
|
1843
2124
|
preferredCount: Array.isArray(def.preferredValues) ? def.preferredValues.length : 0,
|
|
1844
2125
|
});
|
|
1845
2126
|
}
|
|
2127
|
+
else if (def.type === "SLOT") {
|
|
2128
|
+
// For a compositional component the slots ARE the API
|
|
2129
|
+
slots.push({
|
|
2130
|
+
name,
|
|
2131
|
+
preferredCount: Array.isArray(def.preferredValues) ? def.preferredValues.length : 0,
|
|
2132
|
+
description: typeof def.description === "string" && def.description.trim() ? def.description.trim() : null,
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
1846
2135
|
}
|
|
1847
2136
|
// Variant matrix with per-variant color data
|
|
1848
2137
|
if (variants.length > 0 && variantData && variantData.length > 0) {
|
|
@@ -1866,19 +2155,21 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
|
|
|
1866
2155
|
lines.push("|" + separatorParts.join("|") + "|");
|
|
1867
2156
|
for (const vd of variantData) {
|
|
1868
2157
|
const displayName = cleanVariantName(vd.variantName);
|
|
2158
|
+
// A hidden layer never stands in for what renders
|
|
2159
|
+
const painted = (list) => list.find((c) => !c.hidden);
|
|
1869
2160
|
// Get primary fill (background)
|
|
1870
|
-
const bgFill = vd.fills
|
|
2161
|
+
const bgFill = painted(vd.fills);
|
|
1871
2162
|
const bgVal = bgFill
|
|
1872
2163
|
? (bgFill.variableName ? `\`${bgFill.variableName}\` (${bgFill.hex})` : bgFill.hex)
|
|
1873
2164
|
: "—";
|
|
1874
2165
|
// Get primary text/icon color
|
|
1875
|
-
const textColor = vd.textColors
|
|
2166
|
+
const textColor = painted(vd.textColors) || painted(vd.iconColors) || painted(vd.strokes);
|
|
1876
2167
|
const textVal = textColor
|
|
1877
2168
|
? (textColor.variableName ? `\`${textColor.variableName}\` (${textColor.hex})` : textColor.hex)
|
|
1878
2169
|
: "—";
|
|
1879
2170
|
const rowParts = [`**${displayName}**`, bgVal];
|
|
1880
2171
|
if (hasIcons) {
|
|
1881
|
-
rowParts.push(formatIconCell(vd.icons
|
|
2172
|
+
rowParts.push(formatIconCell(vd.icons));
|
|
1882
2173
|
}
|
|
1883
2174
|
rowParts.push(textVal);
|
|
1884
2175
|
lines.push("| " + rowParts.join(" | ") + " |");
|
|
@@ -1893,7 +2184,7 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
|
|
|
1893
2184
|
lines.push("|---------|---------------------|");
|
|
1894
2185
|
for (const vd of variantData) {
|
|
1895
2186
|
const displayName = cleanVariantName(vd.variantName);
|
|
1896
|
-
lines.push(`| ${displayName} | ${formatIconCell(vd.icons
|
|
2187
|
+
lines.push(`| ${displayName} | ${formatIconCell(vd.icons)} |`);
|
|
1897
2188
|
}
|
|
1898
2189
|
lines.push("");
|
|
1899
2190
|
}
|
|
@@ -1908,7 +2199,9 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
|
|
|
1908
2199
|
lines.push("");
|
|
1909
2200
|
}
|
|
1910
2201
|
// Configurable properties table (all property types)
|
|
1911
|
-
|
|
2202
|
+
// `variants` belongs in this guard: the table below renders them as its first
|
|
2203
|
+
// rows, so a set with only VARIANT properties used to get no table at all.
|
|
2204
|
+
if (variants.length > 0 || booleans.length > 0 || textProps.length > 0 || instanceSwaps.length > 0 || slots.length > 0) {
|
|
1912
2205
|
lines.push("### Configurable Properties");
|
|
1913
2206
|
lines.push("");
|
|
1914
2207
|
lines.push("| Property | Type | Default | Description |");
|
|
@@ -1926,6 +2219,10 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
|
|
|
1926
2219
|
const preferred = sw.preferredCount > 0 ? ` (${sw.preferredCount} preferred values)` : "";
|
|
1927
2220
|
lines.push(`| **${sw.name}** | \`instance swap\` | ${sw.defaultName ? `\`${sw.defaultName}\`` : "—"} | Swaps the nested ${sw.name.toLowerCase()} instance${preferred} |`);
|
|
1928
2221
|
}
|
|
2222
|
+
for (const slot of slots) {
|
|
2223
|
+
const preferred = slot.preferredCount > 0 ? ` (${slot.preferredCount} preferred values)` : "";
|
|
2224
|
+
lines.push(`| **${slot.name}** | \`slot\` | — | ${slot.description ?? `Accepts nested content for ${slot.name.toLowerCase()}`}${preferred} |`);
|
|
2225
|
+
}
|
|
1929
2226
|
lines.push("");
|
|
1930
2227
|
}
|
|
1931
2228
|
return lines.join("\n");
|
|
@@ -2022,7 +2319,7 @@ setNode) {
|
|
|
2022
2319
|
// Multi-path icons and repeated shapes would otherwise emit identical rows
|
|
2023
2320
|
const emitted = new Set();
|
|
2024
2321
|
const pushRow = (label, c) => {
|
|
2025
|
-
const row = `| ${label} | ${tokenCell(c)} | ${c.hex} |`;
|
|
2322
|
+
const row = `| ${label}${c.hidden ? " _(hidden layer)_" : ""} | ${tokenCell(c)} | ${c.hex} |`;
|
|
2026
2323
|
if (emitted.has(row))
|
|
2027
2324
|
return;
|
|
2028
2325
|
emitted.add(row);
|
|
@@ -2042,15 +2339,15 @@ setNode) {
|
|
|
2042
2339
|
for (const c of vd.iconColors)
|
|
2043
2340
|
pushRow(c.iconLabel ? `Icon (${c.iconLabel})` : "Icon", c);
|
|
2044
2341
|
// Text colors
|
|
2045
|
-
for (const text of vd.textColors)
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2342
|
+
for (const text of vd.textColors)
|
|
2343
|
+
pushRow(`Text (${text.nodeName})`, text);
|
|
2344
|
+
// Strokes — through the same writer, so a visible underline and a hidden
|
|
2345
|
+
// focus ring sharing one color don't print as two indistinguishable rows
|
|
2346
|
+
for (const stroke of vd.strokes)
|
|
2347
|
+
pushRow(stroke.nodeName ? `Stroke (${stroke.nodeName})` : "Stroke", stroke);
|
|
2348
|
+
// Shadows, blurs, opacity — dropping them would imply the component is flat
|
|
2349
|
+
for (const fx of vd.effects)
|
|
2350
|
+
pushRow(fx.nodeName ? `Effect (${fx.nodeName})` : "Effect", fx);
|
|
2054
2351
|
}
|
|
2055
2352
|
lines.push("");
|
|
2056
2353
|
}
|
|
@@ -2113,7 +2410,7 @@ setNode) {
|
|
|
2113
2410
|
lines.push("|----------|---------------|-------|");
|
|
2114
2411
|
for (const token of spacingTokens) {
|
|
2115
2412
|
const varDisplay = token.variableName ? `\`${token.variableName}\`` : "—";
|
|
2116
|
-
lines.push(`| ${token.property} | ${varDisplay} | ${token.value}
|
|
2413
|
+
lines.push(`| ${token.property} | ${varDisplay} | ${formatSpacingValue(token.value)} |`);
|
|
2117
2414
|
}
|
|
2118
2415
|
lines.push("");
|
|
2119
2416
|
}
|
|
@@ -2343,7 +2640,38 @@ function generateChangelogSection(codeInfo) {
|
|
|
2343
2640
|
// ============================================================================
|
|
2344
2641
|
// New Section Generators (Anatomy, Typography, Content Guidelines, Parity)
|
|
2345
2642
|
// ============================================================================
|
|
2346
|
-
|
|
2643
|
+
/** Layer-structure fingerprint: names, types and nesting — not styling */
|
|
2644
|
+
function structureSignature(node, depth = 0) {
|
|
2645
|
+
if (depth > DOC_TREE_DEPTH)
|
|
2646
|
+
return "";
|
|
2647
|
+
const kids = Array.isArray(node.children) ? node.children.map((c) => structureSignature(c, depth + 1)).join(",") : "";
|
|
2648
|
+
return `${node.type}:${depth === 0 ? "" : node.name}${node.visible === false ? "!" : ""}[${kids}]`;
|
|
2649
|
+
}
|
|
2650
|
+
const CONTAINER_TYPES = new Set(["FRAME", "GROUP", "INSTANCE", "COMPONENT", "COMPONENT_SET", "SECTION", "BOOLEAN_OPERATION"]);
|
|
2651
|
+
/**
|
|
2652
|
+
* Count containers sitting exactly at the fetch-depth limit with no children.
|
|
2653
|
+
* REST cuts a tree off by returning `children: []`, which looks identical to an
|
|
2654
|
+
* empty frame — so at that level an empty container MAY be hiding real content.
|
|
2655
|
+
* `fetchedRoot` is the node the REST call requested (levels count from it).
|
|
2656
|
+
*/
|
|
2657
|
+
export function countPossiblyTruncated(fetchedRoot, fetchDepth = DOC_TREE_DEPTH) {
|
|
2658
|
+
let count = 0;
|
|
2659
|
+
const walk = (node, level) => {
|
|
2660
|
+
if (!node)
|
|
2661
|
+
return;
|
|
2662
|
+
const kids = Array.isArray(node.children) ? node.children : [];
|
|
2663
|
+
if (level >= fetchDepth) {
|
|
2664
|
+
if (CONTAINER_TYPES.has(node.type) && kids.length === 0)
|
|
2665
|
+
count++;
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2668
|
+
for (const child of kids)
|
|
2669
|
+
walk(child, level + 1);
|
|
2670
|
+
};
|
|
2671
|
+
walk(fetchedRoot, 0);
|
|
2672
|
+
return count;
|
|
2673
|
+
}
|
|
2674
|
+
export function generateAnatomySection(node, fetchDepth = DOC_TREE_DEPTH) {
|
|
2347
2675
|
const lines = ["", "## Component Anatomy", ""];
|
|
2348
2676
|
// For COMPONENT_SET, list all variants first
|
|
2349
2677
|
if (node.type === "COMPONENT_SET" && node.children?.length > 0) {
|
|
@@ -2355,25 +2683,81 @@ function generateAnatomySection(node) {
|
|
|
2355
2683
|
}
|
|
2356
2684
|
lines.push("### Design Structure (Figma)");
|
|
2357
2685
|
lines.push("");
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2686
|
+
// Variants can differ STRUCTURALLY (a scrollable variant wraps its list in
|
|
2687
|
+
// scroller frames and adds a fade). Showing one tree would silently drop
|
|
2688
|
+
// everything unique to the others — so: one tree per distinct structure.
|
|
2689
|
+
const MAX_STRUCTURES = 6;
|
|
2690
|
+
const structures = [];
|
|
2691
|
+
if (node.type === "COMPONENT_SET" && node.children?.length > 0) {
|
|
2692
|
+
const bySignature = new Map();
|
|
2693
|
+
for (const variant of node.children) {
|
|
2694
|
+
const sig = structureSignature(variant);
|
|
2695
|
+
if (!bySignature.has(sig)) {
|
|
2696
|
+
bySignature.set(sig, structures.length);
|
|
2697
|
+
structures.push({ node: variant, names: [], props: [] });
|
|
2698
|
+
}
|
|
2699
|
+
const entry = structures[bySignature.get(sig)];
|
|
2700
|
+
entry.names.push(cleanVariantName(variant.name || "Unknown"));
|
|
2701
|
+
entry.props.push(parseVariantProperties(variant.name || ""));
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
else {
|
|
2705
|
+
structures.push({ node, names: [], props: [] });
|
|
2706
|
+
}
|
|
2707
|
+
if (structures.length > 1) {
|
|
2708
|
+
// Which variant property decides the structure, if one does
|
|
2709
|
+
const explanation = explainByVariantProperty(structures.flatMap((st, i) => st.props.map((props) => ({ props, display: String(i) }))));
|
|
2710
|
+
lines.push(`_${node.children.length} variants share ${structures.length} distinct layer structures${explanation ? `, decided by **${explanation.property}**` : ""}._`);
|
|
2711
|
+
lines.push("");
|
|
2712
|
+
structures.slice(0, MAX_STRUCTURES).forEach((st, i) => {
|
|
2713
|
+
const values = explanation?.groups.filter(([, display]) => display === String(i)).map(([pv]) => pv) ?? [];
|
|
2714
|
+
const heading = values.length > 0
|
|
2715
|
+
? `${explanation.property}=${values.join(" | ")}`
|
|
2716
|
+
: scopeLabel(st.names, -1);
|
|
2717
|
+
lines.push(`**${heading}** (${st.names.length} variant${st.names.length === 1 ? "" : "s"})`);
|
|
2718
|
+
lines.push("");
|
|
2719
|
+
lines.push("```");
|
|
2720
|
+
lines.push(buildAnatomyTree(st.node));
|
|
2721
|
+
lines.push("```");
|
|
2722
|
+
lines.push("");
|
|
2723
|
+
});
|
|
2724
|
+
if (structures.length > MAX_STRUCTURES) {
|
|
2725
|
+
lines.push(`_+${structures.length - MAX_STRUCTURES} more distinct structures not shown._`);
|
|
2726
|
+
lines.push("");
|
|
2727
|
+
}
|
|
2364
2728
|
}
|
|
2365
2729
|
else {
|
|
2366
|
-
// Shallow tree (REST API depth limitation)
|
|
2367
2730
|
lines.push("```");
|
|
2368
|
-
lines.push(
|
|
2731
|
+
lines.push(buildAnatomyTree(structures[0].node));
|
|
2369
2732
|
lines.push("```");
|
|
2370
2733
|
lines.push("");
|
|
2371
|
-
lines.push("_Note: Tree depth may be limited by the Figma REST API. Use the Desktop Bridge plugin for full node-level anatomy._");
|
|
2372
2734
|
}
|
|
2373
|
-
|
|
2735
|
+
// Say so when the fetch may have cut the tree off, rather than presenting an
|
|
2736
|
+
// incomplete document as a complete one.
|
|
2737
|
+
const truncated = countPossiblyTruncated(node, fetchDepth);
|
|
2738
|
+
if (truncated > 0) {
|
|
2739
|
+
lines.push(`_Note: ${truncated} layer${truncated === 1 ? "" : "s"} sit${truncated === 1 ? "s" : ""} at the depth limit of this extraction (${fetchDepth} levels) with no visible contents. Anything nested deeper is NOT reflected in this document — colors, typography and structure below that point may be incomplete._`);
|
|
2740
|
+
lines.push("");
|
|
2741
|
+
}
|
|
2374
2742
|
return lines.join("\n");
|
|
2375
2743
|
}
|
|
2376
|
-
function generateTypographySection(node) {
|
|
2744
|
+
export function generateTypographySection(node) {
|
|
2745
|
+
if (node.type === "COMPONENT_SET" && node.children?.length > 1) {
|
|
2746
|
+
const rows = collectTypographyAcrossVariants(node);
|
|
2747
|
+
if (rows.length === 0)
|
|
2748
|
+
return "";
|
|
2749
|
+
const out = ["", "## Typography", ""];
|
|
2750
|
+
const scoped = rows.some((r) => r.scope);
|
|
2751
|
+
out.push(`_Compared across all ${node.children.length} variants${scoped ? " — the Applies to column shows where a style is specific to some of them" : "; every variant uses the same styles"}._`);
|
|
2752
|
+
out.push("");
|
|
2753
|
+
out.push(`| Element | Font | Weight | Size | Line Height | Letter Spacing |${scoped ? " Applies to |" : ""}`);
|
|
2754
|
+
out.push(`|---------|------|--------|------|-------------|----------------|${scoped ? "------------|" : ""}`);
|
|
2755
|
+
for (const { style: ts, scope } of rows) {
|
|
2756
|
+
out.push(`| ${ts.nodeName}${ts.hidden ? " _(hidden layer)_" : ""}${ts.mixed ? " _(mixed styles — base style shown)_" : ""} | ${ts.fontFamily} | ${ts.fontWeightName} (${ts.fontWeight}) | ${ts.fontSize}px | ${ts.lineHeight}px | ${ts.letterSpacing === 0 ? "0" : `${ts.letterSpacing}px`} |${scoped ? ` ${scope || "all variants"} |` : ""}`);
|
|
2757
|
+
}
|
|
2758
|
+
out.push("");
|
|
2759
|
+
return out.join("\n");
|
|
2760
|
+
}
|
|
2377
2761
|
const textStyles = collectTypographyData(node);
|
|
2378
2762
|
if (textStyles.length === 0)
|
|
2379
2763
|
return "";
|
|
@@ -2383,11 +2767,11 @@ function generateTypographySection(node) {
|
|
|
2383
2767
|
// Deduplicate by font properties
|
|
2384
2768
|
const seen = new Set();
|
|
2385
2769
|
for (const ts of textStyles) {
|
|
2386
|
-
const key = `${ts.fontFamily}:${ts.fontWeight}:${ts.fontSize}:${ts.lineHeight}`;
|
|
2770
|
+
const key = `${ts.fontFamily}:${ts.fontWeight}:${ts.fontSize}:${ts.lineHeight}:${ts.hidden ? "h" : ""}:${ts.mixed ? "m" : ""}`;
|
|
2387
2771
|
if (seen.has(key))
|
|
2388
2772
|
continue;
|
|
2389
2773
|
seen.add(key);
|
|
2390
|
-
lines.push(`| ${ts.nodeName} | ${ts.fontFamily} | ${ts.fontWeightName} (${ts.fontWeight}) | ${ts.fontSize}px | ${ts.lineHeight}px | ${ts.letterSpacing === 0 ? "0" : `${ts.letterSpacing}px`} |`);
|
|
2774
|
+
lines.push(`| ${ts.nodeName}${ts.hidden ? " _(hidden layer)_" : ""}${ts.mixed ? " _(mixed styles — base style shown)_" : ""} | ${ts.fontFamily} | ${ts.fontWeightName} (${ts.fontWeight}) | ${ts.fontSize}px | ${ts.lineHeight}px | ${ts.letterSpacing === 0 ? "0" : `${ts.letterSpacing}px`} |`);
|
|
2391
2775
|
}
|
|
2392
2776
|
lines.push("");
|
|
2393
2777
|
return lines.join("\n");
|
|
@@ -2412,59 +2796,63 @@ function generateContentGuidelinesSection(parsedDesc) {
|
|
|
2412
2796
|
}
|
|
2413
2797
|
return lines.join("\n");
|
|
2414
2798
|
}
|
|
2415
|
-
function generateParitySection(node, codeInfo) {
|
|
2799
|
+
export function generateParitySection(node, codeInfo) {
|
|
2416
2800
|
const lines = ["", "## Design-Code Parity", ""];
|
|
2417
|
-
// Variant coverage
|
|
2418
|
-
//
|
|
2419
|
-
|
|
2801
|
+
// Variant coverage — compare each Figma variant PROPERTY with the code prop of
|
|
2802
|
+
// the same name, value by value. (This used to look only for a property
|
|
2803
|
+
// literally named "Variant" and otherwise captured the first property's NAME —
|
|
2804
|
+
// so a Size × State set was reported as having a Figma-only variant "Size".)
|
|
2805
|
+
const normalize = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
2806
|
+
const figmaProps = new Map();
|
|
2420
2807
|
if (node.type === "COMPONENT_SET" && node.children) {
|
|
2421
2808
|
for (const child of node.children) {
|
|
2422
|
-
const
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
}
|
|
2428
|
-
}
|
|
2429
|
-
// Try to extract code variants from variant definition or props
|
|
2430
|
-
const codeVariantsRaw = new Map(); // lowercase → original name
|
|
2431
|
-
if (codeInfo.props) {
|
|
2432
|
-
const variantProp = codeInfo.props.find((p) => p.name.toLowerCase() === "variant");
|
|
2433
|
-
if (variantProp?.type) {
|
|
2434
|
-
// Match both single and double quoted values: "default" or 'default'
|
|
2435
|
-
const matches = variantProp.type.match(/["']([^"']+)["']/g);
|
|
2436
|
-
if (matches) {
|
|
2437
|
-
for (const m of matches) {
|
|
2438
|
-
const raw = m.replace(/["']/g, "");
|
|
2439
|
-
codeVariantsRaw.set(raw.toLowerCase(), raw);
|
|
2440
|
-
}
|
|
2809
|
+
for (const [propName, value] of parseVariantProperties(child.name || "")) {
|
|
2810
|
+
const key = normalize(propName);
|
|
2811
|
+
if (!figmaProps.has(key))
|
|
2812
|
+
figmaProps.set(key, { name: propName, values: new Map() });
|
|
2813
|
+
figmaProps.get(key).values.set(value.toLowerCase(), value);
|
|
2441
2814
|
}
|
|
2442
2815
|
}
|
|
2443
2816
|
}
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2817
|
+
// Code props whose type is a union of string literals: "sm" | "md" | 'lg'
|
|
2818
|
+
const codeProps = new Map();
|
|
2819
|
+
for (const prop of codeInfo.props ?? []) {
|
|
2820
|
+
const literals = prop.type?.match(/["']([^"']+)["']/g);
|
|
2821
|
+
if (!literals)
|
|
2822
|
+
continue;
|
|
2823
|
+
const values = new Map();
|
|
2824
|
+
for (const m of literals) {
|
|
2825
|
+
const raw = m.replace(/["']/g, "");
|
|
2826
|
+
values.set(raw.toLowerCase(), raw);
|
|
2827
|
+
}
|
|
2828
|
+
codeProps.set(normalize(prop.name), { name: prop.name, values });
|
|
2829
|
+
}
|
|
2830
|
+
// Compare properties present on BOTH sides, plus "variant" when either side has
|
|
2831
|
+
// it (the long-standing behavior). A property with no counterpart gets no
|
|
2832
|
+
// claim at all — a boolean in code and a True/False variant in Figma aren't comparable here.
|
|
2833
|
+
const compared = [...figmaProps.keys()].filter((k) => codeProps.has(k));
|
|
2834
|
+
if (!compared.includes("variant") && (figmaProps.has("variant") || codeProps.has("variant")))
|
|
2835
|
+
compared.push("variant");
|
|
2836
|
+
if (compared.length > 0) {
|
|
2837
|
+
const labelProperty = compared.length > 1 || compared[0] !== "variant";
|
|
2447
2838
|
lines.push("### Variant Coverage");
|
|
2448
2839
|
lines.push("");
|
|
2449
2840
|
lines.push("| Variant | In Figma | In Code | Status |");
|
|
2450
2841
|
lines.push("|---------|----------|---------|--------|");
|
|
2451
|
-
for (const key of
|
|
2452
|
-
const
|
|
2453
|
-
const
|
|
2454
|
-
const
|
|
2455
|
-
const
|
|
2456
|
-
const
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2842
|
+
for (const key of compared) {
|
|
2843
|
+
const figma = figmaProps.get(key);
|
|
2844
|
+
const code = codeProps.get(key);
|
|
2845
|
+
const propLabel = figma?.name || code?.name || key;
|
|
2846
|
+
const allValues = new Set([...(figma?.values.keys() ?? []), ...(code?.values.keys() ?? [])]);
|
|
2847
|
+
for (const valueKey of allValues) {
|
|
2848
|
+
const inFigma = figma?.values.has(valueKey) ?? false;
|
|
2849
|
+
const inCode = code?.values.has(valueKey) ?? false;
|
|
2850
|
+
const display = figma?.values.get(valueKey) || code?.values.get(valueKey) || valueKey;
|
|
2851
|
+
const status = inFigma && inCode
|
|
2852
|
+
? "In sync"
|
|
2853
|
+
: inFigma ? "Figma-only — needs code variant" : "Code-only — needs Figma variant";
|
|
2854
|
+
lines.push(`| ${labelProperty ? `${propLabel}: ${display}` : display} | ${inFigma ? "Yes" : "**No**"} | ${inCode ? "Yes" : "**No**"} | ${status} |`);
|
|
2463
2855
|
}
|
|
2464
|
-
else {
|
|
2465
|
-
status = "Code-only — needs Figma variant";
|
|
2466
|
-
}
|
|
2467
|
-
lines.push(`| ${displayName} | ${inFigma ? "Yes" : "**No**"} | ${inCode ? "Yes" : "**No**"} | ${status} |`);
|
|
2468
2856
|
}
|
|
2469
2857
|
lines.push("");
|
|
2470
2858
|
}
|
|
@@ -2917,7 +3305,22 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
|
|
|
2917
3305
|
logger.info({ fileKey, nodeId, enrich }, "Generating component documentation");
|
|
2918
3306
|
const api = await getFigmaAPI();
|
|
2919
3307
|
// Fetch component node with deeper depth for anatomy & per-variant data
|
|
2920
|
-
|
|
3308
|
+
// DOC_TREE_DEPTH, not 4: levels count from the requested node, so on a
|
|
3309
|
+
// COMPONENT_SET depth 4 left each variant only 3 — enough to silently drop
|
|
3310
|
+
// nested labels and a whole scroll mechanism from the generated document.
|
|
3311
|
+
let fetchDepth = DOC_TREE_DEPTH;
|
|
3312
|
+
let nodesResponse;
|
|
3313
|
+
try {
|
|
3314
|
+
nodesResponse = await withTimeout(api.getNodes(fileKey, [nodeId], { depth: DOC_TREE_DEPTH }), DEEP_FETCH_TIMEOUT_MS, "deep component fetch");
|
|
3315
|
+
}
|
|
3316
|
+
catch (deepErr) {
|
|
3317
|
+
// A huge set (hundreds of variants × nested instances) can make the deep
|
|
3318
|
+
// fetch too slow or too large. A shallower document that SAYS it is
|
|
3319
|
+
// shallow beats no document — the truncation note below reports it.
|
|
3320
|
+
fetchDepth = SHALLOW_FETCH_DEPTH;
|
|
3321
|
+
logger.warn({ err: deepErr instanceof Error ? deepErr.message : String(deepErr) }, "Deep fetch failed; retrying component doc fetch at reduced depth");
|
|
3322
|
+
nodesResponse = await api.getNodes(fileKey, [nodeId], { depth: SHALLOW_FETCH_DEPTH });
|
|
3323
|
+
}
|
|
2921
3324
|
const nodeData = nodesResponse?.nodes?.[nodeId];
|
|
2922
3325
|
if (!nodeData?.document) {
|
|
2923
3326
|
throw new Error(`Node ${nodeId} not found in file ${fileKey}`);
|
|
@@ -3005,10 +3408,8 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
|
|
|
3005
3408
|
const varsResult = await connector.getVariables();
|
|
3006
3409
|
const varList = varsResult?.variables || varsResult?.result?.variables;
|
|
3007
3410
|
if (Array.isArray(varList)) {
|
|
3008
|
-
for (const
|
|
3009
|
-
|
|
3010
|
-
varNameMap.set(v.id, v.name);
|
|
3011
|
-
}
|
|
3411
|
+
for (const [id, name] of buildVariableNameMap(varList, varsResult?.variableCollections || varsResult?.result?.variableCollections))
|
|
3412
|
+
varNameMap.set(id, name);
|
|
3012
3413
|
logger.info({ count: varList.length }, "Resolved variable names via Desktop Bridge for docs");
|
|
3013
3414
|
}
|
|
3014
3415
|
}
|
|
@@ -3141,7 +3542,7 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
|
|
|
3141
3542
|
includedSections.push("overview");
|
|
3142
3543
|
}
|
|
3143
3544
|
if (s.anatomy) {
|
|
3144
|
-
const anatomySection = generateAnatomySection(node);
|
|
3545
|
+
const anatomySection = generateAnatomySection(node, fetchDepth);
|
|
3145
3546
|
if (anatomySection.trim()) {
|
|
3146
3547
|
parts.push(anatomySection);
|
|
3147
3548
|
includedSections.push("anatomy");
|