figma-console-mcp 1.40.2 → 1.40.4
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 +4 -2
- package/dist/cloudflare/core/cloud-websocket-connector.js +3 -1
- package/dist/cloudflare/core/design-code-tools.js +622 -168
- package/dist/cloudflare/core/figma-tools.js +5 -3
- package/dist/cloudflare/core/identity.js +48 -1
- package/dist/cloudflare/core/tokens-tools.js +1 -1
- package/dist/cloudflare/core/websocket-connector.js +4 -4
- 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 +622 -168
- package/dist/core/design-code-tools.js.map +1 -1
- package/dist/core/figma-connector.d.ts +8 -2
- package/dist/core/figma-connector.d.ts.map +1 -1
- package/dist/core/figma-tools.d.ts.map +1 -1
- package/dist/core/figma-tools.js +5 -3
- package/dist/core/figma-tools.js.map +1 -1
- package/dist/core/identity.d.ts +16 -0
- package/dist/core/identity.d.ts.map +1 -1
- package/dist/core/identity.js +48 -1
- package/dist/core/identity.js.map +1 -1
- package/dist/core/tokens/schemas.d.ts +2 -2
- package/dist/core/tokens-tools.js +1 -1
- package/dist/core/websocket-connector.d.ts +2 -2
- package/dist/core/websocket-connector.d.ts.map +1 -1
- package/dist/core/websocket-connector.js +4 -4
- package/dist/core/websocket-connector.js.map +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,52 @@ 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 }).toUpperCase() : "";
|
|
380
|
+
// Scaled instances produce values like 0.39000001549720764
|
|
381
|
+
const n = (v) => Math.round((v ?? 0) * 100) / 100;
|
|
382
|
+
return `${effect.type === "DROP_SHADOW" ? "drop" : "inner"} shadow: x ${n(effect.offset?.x)} · y ${n(effect.offset?.y)} · blur ${n(effect.radius)} · spread ${n(effect.spread)}${color ? ` · ${color}` : ""}`;
|
|
383
|
+
}
|
|
384
|
+
const radius = Math.round((effect.radius ?? 0) * 100) / 100;
|
|
385
|
+
if (effect.type === "LAYER_BLUR")
|
|
386
|
+
return `layer blur ${radius}px`;
|
|
387
|
+
if (effect.type === "BACKGROUND_BLUR")
|
|
388
|
+
return `background blur ${radius}px`;
|
|
389
|
+
return `${effect.type.toLowerCase().replace(/_/g, " ")} effect`;
|
|
390
|
+
}
|
|
305
391
|
/** Largest dimension (px) an instance-swap slot can have and still be treated as an icon */
|
|
306
392
|
const MAX_ICON_SLOT_SIZE = 64;
|
|
307
393
|
/**
|
|
@@ -330,17 +416,35 @@ function detectIconInstance(node, lookup) {
|
|
|
330
416
|
if (!nameSaysIcon && !(swapProperty && iconSized))
|
|
331
417
|
return null;
|
|
332
418
|
const cleanedLayerName = layerName.replace(/^icon\s*\/?\s*/i, "").trim() || layerName;
|
|
419
|
+
// Many systems wrap the glyph: an "Icon (small)" instance whose only job is to
|
|
420
|
+
// hold a swappable "MagnifyingGlass". The wrapper says nothing about WHICH icon
|
|
421
|
+
// this is — the instance nested inside it does.
|
|
422
|
+
const glyph = findNestedGlyphName(node, lookup);
|
|
333
423
|
return {
|
|
334
|
-
name: mainName || cleanedLayerName,
|
|
424
|
+
name: glyph || mainName || cleanedLayerName,
|
|
335
425
|
type: "instance",
|
|
336
426
|
layerName,
|
|
337
427
|
...(swapProperty ? { swapProperty } : {}),
|
|
338
428
|
};
|
|
339
429
|
}
|
|
340
|
-
/**
|
|
430
|
+
/** Name of the first instance nested inside an icon wrapper (breadth-first, ≤3 levels) */
|
|
431
|
+
function findNestedGlyphName(icon, lookup) {
|
|
432
|
+
let level = Array.isArray(icon.children) ? icon.children : [];
|
|
433
|
+
for (let depth = 0; depth < 3 && level.length > 0; depth++) {
|
|
434
|
+
for (const child of level) {
|
|
435
|
+
if (child.type === "INSTANCE") {
|
|
436
|
+
const name = resolveMainComponentName(child.componentId, lookup);
|
|
437
|
+
if (name)
|
|
438
|
+
return name;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
level = level.flatMap((c) => (Array.isArray(c.children) ? c.children : []));
|
|
442
|
+
}
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
445
|
+
/** True when the node has at least one visible paint (solid, gradient, image…) */
|
|
341
446
|
function hasVisibleSolidFill(node) {
|
|
342
|
-
return Array.isArray(node?.fills)
|
|
343
|
-
&& node.fills.some((f) => f.type === "SOLID" && f.color && f.visible !== false);
|
|
447
|
+
return Array.isArray(node?.fills) && node.fills.some((f) => describePaint(f) !== null);
|
|
344
448
|
}
|
|
345
449
|
/**
|
|
346
450
|
* When a variant's root is transparent, some systems paint the surface with a
|
|
@@ -386,12 +490,13 @@ export function collectAllVariantData(node, varNameMap, lookup = {}) {
|
|
|
386
490
|
iconColors: [],
|
|
387
491
|
strokes: [],
|
|
388
492
|
textColors: [],
|
|
493
|
+
effects: [],
|
|
389
494
|
icons: [],
|
|
390
495
|
};
|
|
391
496
|
const backgroundLayer = findBackgroundLayer(variant);
|
|
392
497
|
if (backgroundLayer)
|
|
393
498
|
data.backgroundLayer = backgroundLayer.name || "background layer";
|
|
394
|
-
walkVariantNode(variant, data, { varNameMap, lookup, backgroundLayer }, 0,
|
|
499
|
+
walkVariantNode(variant, data, { varNameMap, lookup, backgroundLayer }, 0, DOC_TREE_DEPTH, null);
|
|
395
500
|
variants.push(data);
|
|
396
501
|
}
|
|
397
502
|
return variants;
|
|
@@ -399,26 +504,33 @@ export function collectAllVariantData(node, varNameMap, lookup = {}) {
|
|
|
399
504
|
/** Walk a single variant node tree to collect colors and icons */
|
|
400
505
|
function walkVariantNode(node, data, ctx, depth, maxDepth,
|
|
401
506
|
/** Label of the icon instance we're inside of, or null when outside any icon */
|
|
402
|
-
insideIcon
|
|
507
|
+
insideIcon,
|
|
508
|
+
/** An ancestor is hidden, so nothing beneath it paints either */
|
|
509
|
+
ancestorHidden = false) {
|
|
403
510
|
if (depth > maxDepth)
|
|
404
511
|
return;
|
|
405
512
|
const isText = node.type === "TEXT";
|
|
513
|
+
// Hidden layers are COLLECTED, not skipped: a hidden focus ring is real design
|
|
514
|
+
// intent. They are labeled so a reader can tell it from what actually renders.
|
|
515
|
+
const hidden = ancestorHidden || node.visible === false;
|
|
406
516
|
// Icons are reported once, at the outermost instance — everything beneath it
|
|
407
517
|
// (vectors, nested instances) is that icon's artwork, not a separate icon.
|
|
408
518
|
if (insideIcon === null) {
|
|
409
519
|
const icon = detectIconInstance(node, ctx.lookup);
|
|
410
520
|
if (icon) {
|
|
411
|
-
data.icons.push(icon);
|
|
521
|
+
data.icons.push(hidden ? { ...icon, hidden: true } : icon);
|
|
412
522
|
insideIcon = icon.layerName || icon.name;
|
|
413
523
|
}
|
|
414
524
|
}
|
|
415
|
-
const toEntry = (paint) => {
|
|
525
|
+
const toEntry = (paint, value) => {
|
|
416
526
|
const varId = paint.boundVariables?.color?.id;
|
|
417
527
|
return {
|
|
418
|
-
hex:
|
|
419
|
-
|
|
528
|
+
hex: value,
|
|
529
|
+
// The variant root's own name is "Size=lg, State=hover" — not a layer name
|
|
530
|
+
nodeName: depth === 0 ? "" : node.name || "",
|
|
420
531
|
variableId: varId,
|
|
421
532
|
variableName: varId ? ctx.varNameMap.get(varId) : undefined,
|
|
533
|
+
...(hidden ? { hidden: true } : {}),
|
|
422
534
|
};
|
|
423
535
|
};
|
|
424
536
|
// Collect fills. Only the variant root (or its full-bleed stand-in) is a
|
|
@@ -426,8 +538,9 @@ insideIcon) {
|
|
|
426
538
|
// transparent variant gets documented with its icon's color as the surface.
|
|
427
539
|
if (node.fills && Array.isArray(node.fills)) {
|
|
428
540
|
for (const fill of node.fills) {
|
|
429
|
-
|
|
430
|
-
|
|
541
|
+
const value = describePaint(fill);
|
|
542
|
+
if (value) {
|
|
543
|
+
const entry = toEntry(fill, value);
|
|
431
544
|
if (isText) {
|
|
432
545
|
data.textColors.push(entry);
|
|
433
546
|
}
|
|
@@ -446,8 +559,9 @@ insideIcon) {
|
|
|
446
559
|
// Collect strokes
|
|
447
560
|
if (node.strokes && Array.isArray(node.strokes)) {
|
|
448
561
|
for (const stroke of node.strokes) {
|
|
449
|
-
|
|
450
|
-
|
|
562
|
+
const value = describePaint(stroke);
|
|
563
|
+
if (value) {
|
|
564
|
+
const entry = toEntry(stroke, value);
|
|
451
565
|
if (insideIcon !== null) {
|
|
452
566
|
data.iconColors.push({ ...entry, iconLabel: insideIcon });
|
|
453
567
|
}
|
|
@@ -457,24 +571,44 @@ insideIcon) {
|
|
|
457
571
|
}
|
|
458
572
|
}
|
|
459
573
|
}
|
|
574
|
+
// Effects and opacity — outside icon artwork, which has its own internals
|
|
575
|
+
if (insideIcon === null || depth === 0) {
|
|
576
|
+
if (Array.isArray(node.effects)) {
|
|
577
|
+
for (const effect of node.effects) {
|
|
578
|
+
const value = describeEffect(effect);
|
|
579
|
+
if (value)
|
|
580
|
+
data.effects.push(toEntry(effect, value));
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
if (typeof node.opacity === "number" && node.opacity < 1) {
|
|
584
|
+
data.effects.push({
|
|
585
|
+
...toEntry({}, `opacity ${Math.round(node.opacity * 100)}%`),
|
|
586
|
+
variableId: node.boundVariables?.opacity?.id,
|
|
587
|
+
variableName: node.boundVariables?.opacity?.id ? ctx.varNameMap.get(node.boundVariables.opacity.id) : undefined,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
}
|
|
460
591
|
// Recurse into children
|
|
461
592
|
if (node.children && Array.isArray(node.children)) {
|
|
462
593
|
for (const child of node.children) {
|
|
463
|
-
walkVariantNode(child, data, ctx, depth + 1, maxDepth, insideIcon);
|
|
594
|
+
walkVariantNode(child, data, ctx, depth + 1, maxDepth, insideIcon, hidden);
|
|
464
595
|
}
|
|
465
596
|
}
|
|
466
597
|
}
|
|
467
598
|
/**
|
|
468
599
|
* Collect typography data from all text nodes in a component tree.
|
|
469
600
|
*/
|
|
470
|
-
export function collectTypographyData(node, depth = 0, maxDepth =
|
|
601
|
+
export function collectTypographyData(node, depth = 0, maxDepth = DOC_TREE_DEPTH, ancestorHidden = false) {
|
|
471
602
|
const results = [];
|
|
472
603
|
if (depth > maxDepth)
|
|
473
604
|
return results;
|
|
474
|
-
//
|
|
605
|
+
// A COMPONENT_SET resolves to its default (first) variant HERE ONLY. Anything
|
|
606
|
+
// documenting a set must use collectTypographyAcrossVariants — variants
|
|
607
|
+
// routinely differ in weight (a selected tab is SemiBold, the rest Regular).
|
|
475
608
|
if (node.type === "COMPONENT_SET" && node.children?.length > 0 && depth === 0) {
|
|
476
609
|
return collectTypographyData(node.children[0], 0, maxDepth);
|
|
477
610
|
}
|
|
611
|
+
const hidden = ancestorHidden || node.visible === false;
|
|
478
612
|
if (node.type === "TEXT" && node.style) {
|
|
479
613
|
const s = node.style;
|
|
480
614
|
const weightNames = {
|
|
@@ -489,20 +623,80 @@ export function collectTypographyData(node, depth = 0, maxDepth = 5) {
|
|
|
489
623
|
fontSize: s.fontSize || 14,
|
|
490
624
|
lineHeight: s.lineHeightPx || s.fontSize || 14,
|
|
491
625
|
letterSpacing: s.letterSpacing || 0,
|
|
626
|
+
...(hidden ? { hidden: true } : {}),
|
|
627
|
+
// REST: characterStyleOverrides maps characters → styleOverrideTable ids; any
|
|
628
|
+
// non-zero entry means part of the text departs from `style`
|
|
629
|
+
...(Array.isArray(node.characterStyleOverrides) && node.characterStyleOverrides.some((id) => id !== 0)
|
|
630
|
+
? { mixed: true } : {}),
|
|
492
631
|
});
|
|
493
632
|
}
|
|
494
633
|
if (node.children && Array.isArray(node.children)) {
|
|
495
634
|
for (const child of node.children) {
|
|
496
|
-
results.push(...collectTypographyData(child, depth + 1, maxDepth));
|
|
635
|
+
results.push(...collectTypographyData(child, depth + 1, maxDepth, hidden));
|
|
497
636
|
}
|
|
498
637
|
}
|
|
499
638
|
return results;
|
|
500
639
|
}
|
|
640
|
+
// `hidden` and `mixed` belong in the signature: a label shown in one variant and
|
|
641
|
+
// hidden in another is NOT "the same in all variants".
|
|
642
|
+
const typographySignature = (ts) => `${ts.fontFamily}|${ts.fontWeight}|${ts.fontSize}|${ts.lineHeight}|${ts.letterSpacing}|${ts.hidden ? "hidden" : ""}|${ts.mixed ? "mixed" : ""}`;
|
|
643
|
+
/**
|
|
644
|
+
* Typography for EVERY variant of a set. Per text element: one row when all
|
|
645
|
+
* variants agree, otherwise one row per distinct style, scoped by the variant
|
|
646
|
+
* property that drives it ("Is Selected=True") or, failing that, by variant name.
|
|
647
|
+
*/
|
|
648
|
+
export function collectTypographyAcrossVariants(setNode) {
|
|
649
|
+
const variants = Array.isArray(setNode?.children) ? setNode.children : [];
|
|
650
|
+
// A row's identity is (element name, style) — NOT its position in the tree.
|
|
651
|
+
// Positional identity ("text-8 #4") made ten nav items' badges ten separate
|
|
652
|
+
// elements, each with its own scope, and printed the same fact three times.
|
|
653
|
+
const rowKey = (ts) => `${ts.nodeName}|${typographySignature(ts)}`;
|
|
654
|
+
const perVariant = variants.map((v) => {
|
|
655
|
+
const keys = new Map();
|
|
656
|
+
for (const ts of collectTypographyData(v))
|
|
657
|
+
if (!keys.has(rowKey(ts)))
|
|
658
|
+
keys.set(rowKey(ts), ts);
|
|
659
|
+
return { name: cleanVariantName(v.name || "Unknown"), props: parseVariantProperties(v.name || ""), keys };
|
|
660
|
+
});
|
|
661
|
+
const order = [];
|
|
662
|
+
const styles = new Map();
|
|
663
|
+
for (const pv of perVariant) {
|
|
664
|
+
for (const [key, ts] of pv.keys) {
|
|
665
|
+
if (!styles.has(key)) {
|
|
666
|
+
styles.set(key, ts);
|
|
667
|
+
order.push(key);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return order.map((key) => {
|
|
672
|
+
const style = styles.get(key);
|
|
673
|
+
const having = perVariant.filter((pv) => pv.keys.has(key));
|
|
674
|
+
let scope = "";
|
|
675
|
+
if (having.length < perVariant.length) {
|
|
676
|
+
// Which variant property decides where this (element, style) appears?
|
|
677
|
+
const explanation = explainByVariantProperty(perVariant.map((pv) => ({ props: pv.props, display: pv.keys.has(key) ? "yes" : "no" })));
|
|
678
|
+
const values = explanation?.groups.filter(([, display]) => display === "yes").map(([pv]) => pv) ?? [];
|
|
679
|
+
scope = explanation && values.length > 0
|
|
680
|
+
? `${explanation.property}=${values.join(" | ")}`
|
|
681
|
+
: scopeLabel(having.map((pv) => pv.name), perVariant.length);
|
|
682
|
+
}
|
|
683
|
+
// Text layers are often auto-named after their content — keep the table readable
|
|
684
|
+
const label = style.nodeName.length > 40 ? `${style.nodeName.slice(0, 40)}…` : style.nodeName;
|
|
685
|
+
return { style: { ...style, nodeName: label }, scope };
|
|
686
|
+
});
|
|
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
|
|
@@ -535,7 +729,9 @@ function countChildDepth(node) {
|
|
|
535
729
|
}
|
|
536
730
|
return 1 + max;
|
|
537
731
|
}
|
|
538
|
-
function buildAnatomyLines(node, lines, prefix, isLast, depth, maxDepth
|
|
732
|
+
function buildAnatomyLines(node, lines, prefix, isLast, depth, maxDepth,
|
|
733
|
+
/** Appended to this node's own line — " ×7" for a collapsed run of identical siblings */
|
|
734
|
+
suffix = "") {
|
|
539
735
|
if (depth > maxDepth)
|
|
540
736
|
return;
|
|
541
737
|
const connector = depth === 0 ? "" : (isLast ? "└── " : "├── ");
|
|
@@ -561,25 +757,46 @@ function buildAnatomyLines(node, lines, prefix, isLast, depth, maxDepth) {
|
|
|
561
757
|
let sizingInfo = "";
|
|
562
758
|
if (node.primaryAxisSizingMode || node.counterAxisSizingMode) {
|
|
563
759
|
const parts = [];
|
|
760
|
+
// The PRIMARY axis is width only in a horizontal layout — in a vertical one it
|
|
761
|
+
// is height. (These were hard-wired to width/height, mislabeling every
|
|
762
|
+
// vertical auto-layout: a fixed-width sidebar printed as "fixed-height".)
|
|
763
|
+
const vertical = node.layoutMode === "VERTICAL";
|
|
564
764
|
if (node.primaryAxisSizingMode === "FIXED")
|
|
565
|
-
parts.push("fixed-width");
|
|
765
|
+
parts.push(vertical ? "fixed-height" : "fixed-width");
|
|
566
766
|
if (node.primaryAxisSizingMode === "AUTO")
|
|
567
767
|
parts.push("hug-content");
|
|
568
768
|
if (node.counterAxisSizingMode === "FIXED")
|
|
569
|
-
parts.push("fixed-height");
|
|
769
|
+
parts.push(vertical ? "fixed-width" : "fixed-height");
|
|
570
770
|
if (node.layoutGrow === 1)
|
|
571
771
|
parts.push("fill");
|
|
572
772
|
if (parts.length > 0)
|
|
573
773
|
sizingInfo = ` [${parts.join(", ")}]`;
|
|
574
774
|
}
|
|
575
|
-
|
|
576
|
-
//
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
775
|
+
// Hidden layers stay in the tree, marked: the color table names them (a hidden
|
|
776
|
+
// focus ring is real design intent), so the tree must not pretend they don't exist.
|
|
777
|
+
const hiddenHint = node.visible === false ? " (hidden)" : "";
|
|
778
|
+
lines.push(`${prefix}${connector}${label}${typeHint}${suffix}${hiddenHint}${layoutInfo}${sizingInfo}`);
|
|
779
|
+
// Recurse into children, collapsing RUNS of siblings that would print
|
|
780
|
+
// identically (ten nav items, five list rows) into one entry marked ×N. Compared
|
|
781
|
+
// on the rendered text, so anything the tree shows — a different gap, one hidden
|
|
782
|
+
// caret — keeps a sibling separate.
|
|
783
|
+
if (node.children && Array.isArray(node.children) && depth < maxDepth) {
|
|
784
|
+
const rendered = node.children.map((child) => {
|
|
785
|
+
const own = [];
|
|
786
|
+
buildAnatomyLines(child, own, "", true, depth + 1, maxDepth);
|
|
787
|
+
return own.join("\n");
|
|
788
|
+
});
|
|
789
|
+
const runs = [];
|
|
790
|
+
node.children.forEach((child, i) => {
|
|
791
|
+
const last = runs[runs.length - 1];
|
|
792
|
+
if (last && rendered[i] === rendered[i - 1])
|
|
793
|
+
last.count++;
|
|
794
|
+
else
|
|
795
|
+
runs.push({ child, count: 1 });
|
|
796
|
+
});
|
|
797
|
+
runs.forEach((run, i) => {
|
|
798
|
+
buildAnatomyLines(run.child, lines, prefix + childPrefix, i === runs.length - 1, depth + 1, maxDepth, run.count > 1 ? ` ×${run.count}` : "");
|
|
799
|
+
});
|
|
583
800
|
}
|
|
584
801
|
}
|
|
585
802
|
/**
|
|
@@ -587,6 +804,7 @@ function buildAnatomyLines(node, lines, prefix, isLast, depth, maxDepth) {
|
|
|
587
804
|
*/
|
|
588
805
|
function collectSpacingTokens(node, varNameMap = new Map()) {
|
|
589
806
|
const tokens = [];
|
|
807
|
+
const autoLayout = !!node.layoutMode && node.layoutMode !== "NONE";
|
|
590
808
|
const boundVars = node.boundVariables || {};
|
|
591
809
|
const spacingProps = [
|
|
592
810
|
{ key: "paddingTop", label: "Padding top" },
|
|
@@ -598,7 +816,16 @@ function collectSpacingTokens(node, varNameMap = new Map()) {
|
|
|
598
816
|
{ key: "strokeWeight", label: "Border width" },
|
|
599
817
|
];
|
|
600
818
|
for (const { key, label } of spacingProps) {
|
|
601
|
-
|
|
819
|
+
let value = node[key];
|
|
820
|
+
// Same rules as the cross-variant table: a border width only where a border
|
|
821
|
+
// is painted (and per-side weights win); zero padding is reported, not omitted.
|
|
822
|
+
if (key === "strokeWeight")
|
|
823
|
+
value = hasVisibleStroke(node) ? describeStrokeWeight(node) : undefined;
|
|
824
|
+
else if ((key.startsWith("padding") || key === "itemSpacing") && autoLayout)
|
|
825
|
+
value = value ?? 0;
|
|
826
|
+
else if (key === "cornerRadius" && (value === undefined || value === null) && Array.isArray(node.rectangleCornerRadii)) {
|
|
827
|
+
value = node.rectangleCornerRadii.map((r) => `${r}px`).join(" / ");
|
|
828
|
+
}
|
|
602
829
|
if (value !== undefined && value !== null) {
|
|
603
830
|
const varBinding = boundVars[key];
|
|
604
831
|
// Bound spacing variables expose their id as boundVariables[key].id.
|
|
@@ -615,6 +842,35 @@ function collectSpacingTokens(node, varNameMap = new Map()) {
|
|
|
615
842
|
}
|
|
616
843
|
return tokens;
|
|
617
844
|
}
|
|
845
|
+
/** True when the node paints at least one stroke */
|
|
846
|
+
function hasVisibleStroke(node) {
|
|
847
|
+
return Array.isArray(node?.strokes) && node.strokes.some((st) => describePaint(st) !== null);
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* The border width that actually RENDERS. With per-side weights (underlines,
|
|
851
|
+
* dividers, accent bars) the scalar `strokeWeight` is a leftover from before the
|
|
852
|
+
* override and appears nowhere in the component — `individualStrokeWeights` wins.
|
|
853
|
+
*/
|
|
854
|
+
export function describeStrokeWeight(node) {
|
|
855
|
+
if (!hasVisibleStroke(node))
|
|
856
|
+
return "none";
|
|
857
|
+
const sides = node.individualStrokeWeights;
|
|
858
|
+
if (sides && typeof sides === "object") {
|
|
859
|
+
const order = [
|
|
860
|
+
["top", sides.top ?? 0], ["right", sides.right ?? 0],
|
|
861
|
+
["bottom", sides.bottom ?? 0], ["left", sides.left ?? 0],
|
|
862
|
+
];
|
|
863
|
+
const weights = order.map(([, w]) => w);
|
|
864
|
+
if (weights.every((w) => w === weights[0]))
|
|
865
|
+
return weights[0];
|
|
866
|
+
const painted = order.filter(([, w]) => w > 0);
|
|
867
|
+
// "4px bottom" reads better than "0 / 0 / 4 / 0" when few sides are in play
|
|
868
|
+
if (painted.length <= 2)
|
|
869
|
+
return painted.map(([side, w]) => `${w}px ${side}`).join(", ");
|
|
870
|
+
return `${weights.map((w) => `${w}px`).join(" / ")} (top / right / bottom / left)`;
|
|
871
|
+
}
|
|
872
|
+
return node.strokeWeight ?? 0;
|
|
873
|
+
}
|
|
618
874
|
/** Parse "Size=lg, State=hover" into ordered [property, value] pairs */
|
|
619
875
|
export function parseVariantProperties(rawName) {
|
|
620
876
|
const pairs = [];
|
|
@@ -626,6 +882,38 @@ export function parseVariantProperties(rawName) {
|
|
|
626
882
|
}
|
|
627
883
|
return pairs;
|
|
628
884
|
}
|
|
885
|
+
/**
|
|
886
|
+
* Find ONE variant property that fully accounts for how a value differs across
|
|
887
|
+
* variants: grouping by that property leaves a single value per group.
|
|
888
|
+
*
|
|
889
|
+
* The explanation must be non-vacuous — with one variant per group any values
|
|
890
|
+
* at all would "fit" (single-property sets), so that doesn't count.
|
|
891
|
+
* Shared by spacing, typography and anatomy so they explain variation alike.
|
|
892
|
+
*/
|
|
893
|
+
export function explainByVariantProperty(samples) {
|
|
894
|
+
if (samples.length < 2)
|
|
895
|
+
return null;
|
|
896
|
+
for (const [propName] of samples[0].props) {
|
|
897
|
+
const groups = new Map();
|
|
898
|
+
let complete = true;
|
|
899
|
+
for (const x of samples) {
|
|
900
|
+
const propValue = x.props.find(([n]) => n === propName)?.[1];
|
|
901
|
+
if (propValue === undefined) {
|
|
902
|
+
complete = false;
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
if (!groups.has(propValue))
|
|
906
|
+
groups.set(propValue, new Set());
|
|
907
|
+
groups.get(propValue).add(x.display);
|
|
908
|
+
}
|
|
909
|
+
if (!complete)
|
|
910
|
+
continue;
|
|
911
|
+
if (groups.size > 1 && groups.size < samples.length && [...groups.values()].every((vals) => vals.size === 1)) {
|
|
912
|
+
return { property: propName, groups: [...groups.entries()].map(([pv, vals]) => [pv, [...vals][0]]) };
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return null;
|
|
916
|
+
}
|
|
629
917
|
const SPACING_COMPARISON_PROPS = [
|
|
630
918
|
{ key: "paddingTop", label: "Padding top" },
|
|
631
919
|
{ key: "paddingRight", label: "Padding right" },
|
|
@@ -662,9 +950,17 @@ export function collectSpacingAcrossVariants(setNode, varNameMap = new Map()) {
|
|
|
662
950
|
if (variants.length === 0)
|
|
663
951
|
return result;
|
|
664
952
|
for (const { key, label } of SPACING_COMPARISON_PROPS) {
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
|
|
953
|
+
const isPadding = key.startsWith("padding") || key === "itemSpacing";
|
|
954
|
+
const isSet = (v) => (key === "strokeWeight"
|
|
955
|
+
// A border width only means something where a border is painted. REST
|
|
956
|
+
// reports strokeWeight (default 1) on every node, stroked or not.
|
|
957
|
+
? hasVisibleStroke(v)
|
|
958
|
+
: (v[key] !== undefined && v[key] !== null)
|
|
959
|
+
// Per-corner rounding: REST drops the scalar and sends only the 4-tuple
|
|
960
|
+
|| (key === "cornerRadius" && Array.isArray(v.rectangleCornerRadii))
|
|
961
|
+
// Auto-layout: REST omits zero-valued padding/gap, but 0 is a real,
|
|
962
|
+
// reportable value — leaving it out is indistinguishable from "unknown"
|
|
963
|
+
|| (isPadding && !!v.layoutMode && v.layoutMode !== "NONE"));
|
|
668
964
|
if (!variants.some(isSet))
|
|
669
965
|
continue;
|
|
670
966
|
const samples = variants.map((v) => {
|
|
@@ -673,6 +969,8 @@ export function collectSpacingAcrossVariants(setNode, varNameMap = new Map()) {
|
|
|
673
969
|
if (key === "cornerRadius" && (v[key] === undefined || v[key] === null) && Array.isArray(v.rectangleCornerRadii)) {
|
|
674
970
|
value = v.rectangleCornerRadii.map((r) => `${r}px`).join(" / ");
|
|
675
971
|
}
|
|
972
|
+
if (key === "strokeWeight")
|
|
973
|
+
value = describeStrokeWeight(v);
|
|
676
974
|
const varId = typeof v.boundVariables?.[key]?.id === "string"
|
|
677
975
|
? v.boundVariables[key].id
|
|
678
976
|
: undefined;
|
|
@@ -714,31 +1012,20 @@ export function collectSpacingAcrossVariants(setNode, varNameMap = new Map()) {
|
|
|
714
1012
|
: formatSpacingValue(x.value);
|
|
715
1013
|
}
|
|
716
1014
|
// 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
|
-
}
|
|
1015
|
+
const explanation = explainByVariantProperty(samples);
|
|
1016
|
+
const explainedBy = explanation?.property ?? null;
|
|
1017
|
+
const explainedGroups = explanation?.groups ?? [];
|
|
739
1018
|
let valueCell;
|
|
740
1019
|
if (explainedBy) {
|
|
741
|
-
|
|
1020
|
+
// Property values that land on the same result read better together:
|
|
1021
|
+
// "Active, Default, Hover 2px bottom · Focus 2px"
|
|
1022
|
+
const byResult = new Map();
|
|
1023
|
+
for (const [pv, val] of explainedGroups) {
|
|
1024
|
+
if (!byResult.has(val))
|
|
1025
|
+
byResult.set(val, []);
|
|
1026
|
+
byResult.get(val).push(pv);
|
|
1027
|
+
}
|
|
1028
|
+
valueCell = `varies by **${explainedBy}**: ${[...byResult.entries()].map(([val, pvs]) => `${pvs.join(", ")} ${val}`).join(" · ")}`;
|
|
742
1029
|
}
|
|
743
1030
|
else {
|
|
744
1031
|
// No pattern — list each distinct value, most common first, naming the rare ones
|
|
@@ -1691,19 +1978,19 @@ async function detectAtomicLevel(api, fileKey, nodeId, setNodeId, _componentMeta
|
|
|
1691
1978
|
return null;
|
|
1692
1979
|
}
|
|
1693
1980
|
}
|
|
1694
|
-
function generateFrontmatter(componentName, description, node, componentMeta, fileUrl, codeInfo, canonicalSource, level,
|
|
1981
|
+
export function generateFrontmatter(componentName, description, node, componentMeta, fileUrl, codeInfo, canonicalSource, level,
|
|
1695
1982
|
/**
|
|
1696
1983
|
* Newest Figma version that touched this component, when design history was
|
|
1697
1984
|
* pulled. Emitted as its own `figmaVersion` field rather than folded into
|
|
1698
1985
|
* `version` — that one is the code-side semver and means something different.
|
|
1699
1986
|
*/
|
|
1700
1987
|
latestDesignVersion) {
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
const version = codeInfo?.changelog?.[0]?.version ||
|
|
1988
|
+
// Only state what is actually known. "stable" / "1.0.0" used to be emitted for
|
|
1989
|
+
// every component and read as extracted facts.
|
|
1990
|
+
const status = componentMeta?.description?.toLowerCase().includes("deprecated")
|
|
1991
|
+
? "deprecated"
|
|
1992
|
+
: codeInfo?.changelog?.[0] ? "stable" : null;
|
|
1993
|
+
const version = codeInfo?.changelog?.[0]?.version || null;
|
|
1707
1994
|
const tags = [componentName.toLowerCase()];
|
|
1708
1995
|
if (level)
|
|
1709
1996
|
tags.push(level);
|
|
@@ -1714,9 +2001,9 @@ latestDesignVersion) {
|
|
|
1714
2001
|
const lines = [
|
|
1715
2002
|
"---",
|
|
1716
2003
|
`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}
|
|
2004
|
+
...((((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])}`] : []),
|
|
2005
|
+
...(status ? [`status: ${status}`] : []),
|
|
2006
|
+
...(version ? [`version: ${version}`] : []),
|
|
1720
2007
|
`category: components`,
|
|
1721
2008
|
...(level ? [`level: ${level}`] : []),
|
|
1722
2009
|
`tags: [${tags.join(", ")}]`,
|
|
@@ -1748,7 +2035,7 @@ latestDesignVersion) {
|
|
|
1748
2035
|
lines.push("---");
|
|
1749
2036
|
return lines.join("\n");
|
|
1750
2037
|
}
|
|
1751
|
-
function generateOverviewSection(componentName, description, fileUrl, parsedDesc, codeInfo) {
|
|
2038
|
+
export function generateOverviewSection(componentName, description, fileUrl, parsedDesc, codeInfo) {
|
|
1752
2039
|
const lines = [
|
|
1753
2040
|
`# ${componentName}`,
|
|
1754
2041
|
"",
|
|
@@ -1765,50 +2052,90 @@ function generateOverviewSection(componentName, description, fileUrl, parsedDesc
|
|
|
1765
2052
|
}
|
|
1766
2053
|
lines.push(links.join(" | "));
|
|
1767
2054
|
lines.push("");
|
|
1768
|
-
|
|
1769
|
-
|
|
2055
|
+
// Collected separately so an empty overview doesn't leave an orphaned heading
|
|
2056
|
+
const section = [];
|
|
1770
2057
|
// Use parsed overview or fall back to raw description
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
2058
|
+
// No filler: libraries that deliberately keep descriptions empty would get a
|
|
2059
|
+
// sentence carrying no information on every page. Nothing says more than that.
|
|
2060
|
+
const overviewText = parsedDesc.overview || description?.split("\n")[0] || "";
|
|
2061
|
+
if (overviewText.trim()) {
|
|
2062
|
+
section.push(overviewText);
|
|
2063
|
+
section.push("");
|
|
2064
|
+
}
|
|
1774
2065
|
// Base component attribution
|
|
1775
2066
|
if (codeInfo?.baseComponent) {
|
|
1776
2067
|
const baseLink = codeInfo.baseComponent.url
|
|
1777
2068
|
? `[${codeInfo.baseComponent.name}](${codeInfo.baseComponent.url})`
|
|
1778
2069
|
: codeInfo.baseComponent.name;
|
|
1779
2070
|
if (codeInfo.baseComponent.description) {
|
|
1780
|
-
|
|
2071
|
+
section.push(`Built on ${baseLink}, ${codeInfo.baseComponent.description}`);
|
|
1781
2072
|
}
|
|
1782
2073
|
else {
|
|
1783
|
-
|
|
2074
|
+
section.push(`Built on ${baseLink}.`);
|
|
1784
2075
|
}
|
|
1785
|
-
|
|
2076
|
+
section.push("");
|
|
1786
2077
|
}
|
|
1787
2078
|
// When to Use
|
|
1788
2079
|
if (parsedDesc.whenToUse.length > 0) {
|
|
1789
|
-
|
|
1790
|
-
|
|
2080
|
+
section.push("### When to Use");
|
|
2081
|
+
section.push("");
|
|
1791
2082
|
for (const item of parsedDesc.whenToUse) {
|
|
1792
|
-
|
|
2083
|
+
section.push(`- ${item}`);
|
|
1793
2084
|
}
|
|
1794
|
-
|
|
2085
|
+
section.push("");
|
|
1795
2086
|
}
|
|
1796
2087
|
// When NOT to Use
|
|
1797
2088
|
if (parsedDesc.whenNotToUse.length > 0) {
|
|
1798
|
-
|
|
1799
|
-
|
|
2089
|
+
section.push("### When NOT to Use");
|
|
2090
|
+
section.push("");
|
|
1800
2091
|
for (const item of parsedDesc.whenNotToUse) {
|
|
1801
|
-
|
|
2092
|
+
section.push(`- ${item}`);
|
|
1802
2093
|
}
|
|
2094
|
+
section.push("");
|
|
2095
|
+
}
|
|
2096
|
+
if (section.length > 0) {
|
|
2097
|
+
lines.push("## Overview");
|
|
1803
2098
|
lines.push("");
|
|
2099
|
+
lines.push(...section);
|
|
1804
2100
|
}
|
|
1805
2101
|
return lines.join("\n");
|
|
1806
2102
|
}
|
|
1807
2103
|
/** Icon cell text: the icon in the slot, flagged when it's only an instance-swap default */
|
|
1808
|
-
function formatIconCell(
|
|
1809
|
-
if (!
|
|
2104
|
+
function formatIconCell(icons) {
|
|
2105
|
+
if (!icons || icons.length === 0)
|
|
1810
2106
|
return "—";
|
|
1811
|
-
|
|
2107
|
+
// A chip with a leading AND a trailing icon has two — show both. A nav with
|
|
2108
|
+
// twenty repeats of the same one shows it once, counted.
|
|
2109
|
+
const groups = new Map();
|
|
2110
|
+
for (const icon of icons) {
|
|
2111
|
+
const key = `${icon.name}|${icon.swapProperty ?? ""}`;
|
|
2112
|
+
if (!groups.has(key))
|
|
2113
|
+
groups.set(key, { icon, total: 0, hidden: 0 });
|
|
2114
|
+
const g = groups.get(key);
|
|
2115
|
+
g.total++;
|
|
2116
|
+
if (icon.hidden)
|
|
2117
|
+
g.hidden++;
|
|
2118
|
+
}
|
|
2119
|
+
const MAX_ICON_GROUPS = 5;
|
|
2120
|
+
const parts = [...groups.values()].slice(0, MAX_ICON_GROUPS).map(({ icon, total, hidden }) => {
|
|
2121
|
+
if (total === 1)
|
|
2122
|
+
return formatOneIcon(icon);
|
|
2123
|
+
// Count first, then the note: "Placeholder ×9 _(all hidden)_"
|
|
2124
|
+
const label = formatOneIcon({ ...icon, hidden: false });
|
|
2125
|
+
const note = hidden === 0 ? "" : hidden === total ? " _(all hidden)_" : ` _(${hidden} hidden)_`;
|
|
2126
|
+
return `${label} ×${total}${note}`;
|
|
2127
|
+
});
|
|
2128
|
+
if (groups.size > MAX_ICON_GROUPS)
|
|
2129
|
+
parts.push(`+${groups.size - MAX_ICON_GROUPS} more`);
|
|
2130
|
+
return parts.join("; ");
|
|
2131
|
+
}
|
|
2132
|
+
function formatOneIcon(icon) {
|
|
2133
|
+
const notes = [];
|
|
2134
|
+
if (icon.swapProperty)
|
|
2135
|
+
notes.push(`default — swappable via **${icon.swapProperty}**`);
|
|
2136
|
+
if (icon.hidden)
|
|
2137
|
+
notes.push("hidden in this variant");
|
|
2138
|
+
return notes.length > 0 ? `${icon.name} _(${notes.join("; ")})_` : icon.name;
|
|
1812
2139
|
}
|
|
1813
2140
|
export function generateStatesAndVariantsSection(node, variantData, lookup = {}) {
|
|
1814
2141
|
const props = node.componentPropertyDefinitions;
|
|
@@ -1819,6 +2146,7 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
|
|
|
1819
2146
|
const booleans = [];
|
|
1820
2147
|
const textProps = [];
|
|
1821
2148
|
const instanceSwaps = [];
|
|
2149
|
+
const slots = [];
|
|
1822
2150
|
for (const [rawName, def] of Object.entries(props)) {
|
|
1823
2151
|
// Strip Figma internal ID suffixes like "#17100:0" from property names
|
|
1824
2152
|
const name = stripPropertyIdSuffix(rawName);
|
|
@@ -1843,6 +2171,14 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
|
|
|
1843
2171
|
preferredCount: Array.isArray(def.preferredValues) ? def.preferredValues.length : 0,
|
|
1844
2172
|
});
|
|
1845
2173
|
}
|
|
2174
|
+
else if (def.type === "SLOT") {
|
|
2175
|
+
// For a compositional component the slots ARE the API
|
|
2176
|
+
slots.push({
|
|
2177
|
+
name,
|
|
2178
|
+
preferredCount: Array.isArray(def.preferredValues) ? def.preferredValues.length : 0,
|
|
2179
|
+
description: typeof def.description === "string" && def.description.trim() ? def.description.trim() : null,
|
|
2180
|
+
});
|
|
2181
|
+
}
|
|
1846
2182
|
}
|
|
1847
2183
|
// Variant matrix with per-variant color data
|
|
1848
2184
|
if (variants.length > 0 && variantData && variantData.length > 0) {
|
|
@@ -1866,19 +2202,21 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
|
|
|
1866
2202
|
lines.push("|" + separatorParts.join("|") + "|");
|
|
1867
2203
|
for (const vd of variantData) {
|
|
1868
2204
|
const displayName = cleanVariantName(vd.variantName);
|
|
2205
|
+
// A hidden layer never stands in for what renders
|
|
2206
|
+
const painted = (list) => list.find((c) => !c.hidden);
|
|
1869
2207
|
// Get primary fill (background)
|
|
1870
|
-
const bgFill = vd.fills
|
|
2208
|
+
const bgFill = painted(vd.fills);
|
|
1871
2209
|
const bgVal = bgFill
|
|
1872
2210
|
? (bgFill.variableName ? `\`${bgFill.variableName}\` (${bgFill.hex})` : bgFill.hex)
|
|
1873
2211
|
: "—";
|
|
1874
2212
|
// Get primary text/icon color
|
|
1875
|
-
const textColor = vd.textColors
|
|
2213
|
+
const textColor = painted(vd.textColors) || painted(vd.iconColors) || painted(vd.strokes);
|
|
1876
2214
|
const textVal = textColor
|
|
1877
2215
|
? (textColor.variableName ? `\`${textColor.variableName}\` (${textColor.hex})` : textColor.hex)
|
|
1878
2216
|
: "—";
|
|
1879
2217
|
const rowParts = [`**${displayName}**`, bgVal];
|
|
1880
2218
|
if (hasIcons) {
|
|
1881
|
-
rowParts.push(formatIconCell(vd.icons
|
|
2219
|
+
rowParts.push(formatIconCell(vd.icons));
|
|
1882
2220
|
}
|
|
1883
2221
|
rowParts.push(textVal);
|
|
1884
2222
|
lines.push("| " + rowParts.join(" | ") + " |");
|
|
@@ -1893,7 +2231,7 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
|
|
|
1893
2231
|
lines.push("|---------|---------------------|");
|
|
1894
2232
|
for (const vd of variantData) {
|
|
1895
2233
|
const displayName = cleanVariantName(vd.variantName);
|
|
1896
|
-
lines.push(`| ${displayName} | ${formatIconCell(vd.icons
|
|
2234
|
+
lines.push(`| ${displayName} | ${formatIconCell(vd.icons)} |`);
|
|
1897
2235
|
}
|
|
1898
2236
|
lines.push("");
|
|
1899
2237
|
}
|
|
@@ -1908,7 +2246,9 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
|
|
|
1908
2246
|
lines.push("");
|
|
1909
2247
|
}
|
|
1910
2248
|
// Configurable properties table (all property types)
|
|
1911
|
-
|
|
2249
|
+
// `variants` belongs in this guard: the table below renders them as its first
|
|
2250
|
+
// rows, so a set with only VARIANT properties used to get no table at all.
|
|
2251
|
+
if (variants.length > 0 || booleans.length > 0 || textProps.length > 0 || instanceSwaps.length > 0 || slots.length > 0) {
|
|
1912
2252
|
lines.push("### Configurable Properties");
|
|
1913
2253
|
lines.push("");
|
|
1914
2254
|
lines.push("| Property | Type | Default | Description |");
|
|
@@ -1926,6 +2266,10 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
|
|
|
1926
2266
|
const preferred = sw.preferredCount > 0 ? ` (${sw.preferredCount} preferred values)` : "";
|
|
1927
2267
|
lines.push(`| **${sw.name}** | \`instance swap\` | ${sw.defaultName ? `\`${sw.defaultName}\`` : "—"} | Swaps the nested ${sw.name.toLowerCase()} instance${preferred} |`);
|
|
1928
2268
|
}
|
|
2269
|
+
for (const slot of slots) {
|
|
2270
|
+
const preferred = slot.preferredCount > 0 ? ` (${slot.preferredCount} preferred values)` : "";
|
|
2271
|
+
lines.push(`| **${slot.name}** | \`slot\` | — | ${slot.description ?? `Accepts nested content for ${slot.name.toLowerCase()}`}${preferred} |`);
|
|
2272
|
+
}
|
|
1929
2273
|
lines.push("");
|
|
1930
2274
|
}
|
|
1931
2275
|
return lines.join("\n");
|
|
@@ -2021,8 +2365,10 @@ setNode) {
|
|
|
2021
2365
|
};
|
|
2022
2366
|
// Multi-path icons and repeated shapes would otherwise emit identical rows
|
|
2023
2367
|
const emitted = new Set();
|
|
2368
|
+
// Text layers are often auto-named after their content ("Lorem ipsum dolor…")
|
|
2369
|
+
const short = (name) => (name.length > 40 ? `${name.slice(0, 40)}…` : name);
|
|
2024
2370
|
const pushRow = (label, c) => {
|
|
2025
|
-
const row = `| ${label} | ${tokenCell(c)} | ${c.hex} |`;
|
|
2371
|
+
const row = `| ${label}${c.hidden ? " _(hidden layer)_" : ""} | ${tokenCell(c)} | ${c.hex} |`;
|
|
2026
2372
|
if (emitted.has(row))
|
|
2027
2373
|
return;
|
|
2028
2374
|
emitted.add(row);
|
|
@@ -2042,15 +2388,15 @@ setNode) {
|
|
|
2042
2388
|
for (const c of vd.iconColors)
|
|
2043
2389
|
pushRow(c.iconLabel ? `Icon (${c.iconLabel})` : "Icon", c);
|
|
2044
2390
|
// Text colors
|
|
2045
|
-
for (const text of vd.textColors)
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2391
|
+
for (const text of vd.textColors)
|
|
2392
|
+
pushRow(`Text (${short(text.nodeName)})`, text);
|
|
2393
|
+
// Strokes — through the same writer, so a visible underline and a hidden
|
|
2394
|
+
// focus ring sharing one color don't print as two indistinguishable rows
|
|
2395
|
+
for (const stroke of vd.strokes)
|
|
2396
|
+
pushRow(stroke.nodeName ? `Stroke (${stroke.nodeName})` : "Stroke", stroke);
|
|
2397
|
+
// Shadows, blurs, opacity — dropping them would imply the component is flat
|
|
2398
|
+
for (const fx of vd.effects)
|
|
2399
|
+
pushRow(fx.nodeName ? `Effect (${fx.nodeName})` : "Effect", fx);
|
|
2054
2400
|
}
|
|
2055
2401
|
lines.push("");
|
|
2056
2402
|
}
|
|
@@ -2113,7 +2459,7 @@ setNode) {
|
|
|
2113
2459
|
lines.push("|----------|---------------|-------|");
|
|
2114
2460
|
for (const token of spacingTokens) {
|
|
2115
2461
|
const varDisplay = token.variableName ? `\`${token.variableName}\`` : "—";
|
|
2116
|
-
lines.push(`| ${token.property} | ${varDisplay} | ${token.value}
|
|
2462
|
+
lines.push(`| ${token.property} | ${varDisplay} | ${formatSpacingValue(token.value)} |`);
|
|
2117
2463
|
}
|
|
2118
2464
|
lines.push("");
|
|
2119
2465
|
}
|
|
@@ -2343,7 +2689,38 @@ function generateChangelogSection(codeInfo) {
|
|
|
2343
2689
|
// ============================================================================
|
|
2344
2690
|
// New Section Generators (Anatomy, Typography, Content Guidelines, Parity)
|
|
2345
2691
|
// ============================================================================
|
|
2346
|
-
|
|
2692
|
+
/** Layer-structure fingerprint: names, types and nesting — not styling */
|
|
2693
|
+
function structureSignature(node, depth = 0) {
|
|
2694
|
+
if (depth > DOC_TREE_DEPTH)
|
|
2695
|
+
return "";
|
|
2696
|
+
const kids = Array.isArray(node.children) ? node.children.map((c) => structureSignature(c, depth + 1)).join(",") : "";
|
|
2697
|
+
return `${node.type}:${depth === 0 ? "" : node.name}${node.visible === false ? "!" : ""}[${kids}]`;
|
|
2698
|
+
}
|
|
2699
|
+
const CONTAINER_TYPES = new Set(["FRAME", "GROUP", "INSTANCE", "COMPONENT", "COMPONENT_SET", "SECTION", "BOOLEAN_OPERATION"]);
|
|
2700
|
+
/**
|
|
2701
|
+
* Count containers sitting exactly at the fetch-depth limit with no children.
|
|
2702
|
+
* REST cuts a tree off by returning `children: []`, which looks identical to an
|
|
2703
|
+
* empty frame — so at that level an empty container MAY be hiding real content.
|
|
2704
|
+
* `fetchedRoot` is the node the REST call requested (levels count from it).
|
|
2705
|
+
*/
|
|
2706
|
+
export function countPossiblyTruncated(fetchedRoot, fetchDepth = DOC_TREE_DEPTH) {
|
|
2707
|
+
let count = 0;
|
|
2708
|
+
const walk = (node, level) => {
|
|
2709
|
+
if (!node)
|
|
2710
|
+
return;
|
|
2711
|
+
const kids = Array.isArray(node.children) ? node.children : [];
|
|
2712
|
+
if (level >= fetchDepth) {
|
|
2713
|
+
if (CONTAINER_TYPES.has(node.type) && kids.length === 0)
|
|
2714
|
+
count++;
|
|
2715
|
+
return;
|
|
2716
|
+
}
|
|
2717
|
+
for (const child of kids)
|
|
2718
|
+
walk(child, level + 1);
|
|
2719
|
+
};
|
|
2720
|
+
walk(fetchedRoot, 0);
|
|
2721
|
+
return count;
|
|
2722
|
+
}
|
|
2723
|
+
export function generateAnatomySection(node, fetchDepth = DOC_TREE_DEPTH) {
|
|
2347
2724
|
const lines = ["", "## Component Anatomy", ""];
|
|
2348
2725
|
// For COMPONENT_SET, list all variants first
|
|
2349
2726
|
if (node.type === "COMPONENT_SET" && node.children?.length > 0) {
|
|
@@ -2355,25 +2732,81 @@ function generateAnatomySection(node) {
|
|
|
2355
2732
|
}
|
|
2356
2733
|
lines.push("### Design Structure (Figma)");
|
|
2357
2734
|
lines.push("");
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2735
|
+
// Variants can differ STRUCTURALLY (a scrollable variant wraps its list in
|
|
2736
|
+
// scroller frames and adds a fade). Showing one tree would silently drop
|
|
2737
|
+
// everything unique to the others — so: one tree per distinct structure.
|
|
2738
|
+
const MAX_STRUCTURES = 6;
|
|
2739
|
+
const structures = [];
|
|
2740
|
+
if (node.type === "COMPONENT_SET" && node.children?.length > 0) {
|
|
2741
|
+
const bySignature = new Map();
|
|
2742
|
+
for (const variant of node.children) {
|
|
2743
|
+
const sig = structureSignature(variant);
|
|
2744
|
+
if (!bySignature.has(sig)) {
|
|
2745
|
+
bySignature.set(sig, structures.length);
|
|
2746
|
+
structures.push({ node: variant, names: [], props: [] });
|
|
2747
|
+
}
|
|
2748
|
+
const entry = structures[bySignature.get(sig)];
|
|
2749
|
+
entry.names.push(cleanVariantName(variant.name || "Unknown"));
|
|
2750
|
+
entry.props.push(parseVariantProperties(variant.name || ""));
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
else {
|
|
2754
|
+
structures.push({ node, names: [], props: [] });
|
|
2755
|
+
}
|
|
2756
|
+
if (structures.length > 1) {
|
|
2757
|
+
// Which variant property decides the structure, if one does
|
|
2758
|
+
const explanation = explainByVariantProperty(structures.flatMap((st, i) => st.props.map((props) => ({ props, display: String(i) }))));
|
|
2759
|
+
lines.push(`_${node.children.length} variants share ${structures.length} distinct layer structures${explanation ? `, decided by **${explanation.property}**` : ""}._`);
|
|
2760
|
+
lines.push("");
|
|
2761
|
+
structures.slice(0, MAX_STRUCTURES).forEach((st, i) => {
|
|
2762
|
+
const values = explanation?.groups.filter(([, display]) => display === String(i)).map(([pv]) => pv) ?? [];
|
|
2763
|
+
const heading = values.length > 0
|
|
2764
|
+
? `${explanation.property}=${values.join(" | ")}`
|
|
2765
|
+
: scopeLabel(st.names, -1);
|
|
2766
|
+
lines.push(`**${heading}** (${st.names.length} variant${st.names.length === 1 ? "" : "s"})`);
|
|
2767
|
+
lines.push("");
|
|
2768
|
+
lines.push("```");
|
|
2769
|
+
lines.push(buildAnatomyTree(st.node));
|
|
2770
|
+
lines.push("```");
|
|
2771
|
+
lines.push("");
|
|
2772
|
+
});
|
|
2773
|
+
if (structures.length > MAX_STRUCTURES) {
|
|
2774
|
+
lines.push(`_+${structures.length - MAX_STRUCTURES} more distinct structures not shown._`);
|
|
2775
|
+
lines.push("");
|
|
2776
|
+
}
|
|
2364
2777
|
}
|
|
2365
2778
|
else {
|
|
2366
|
-
// Shallow tree (REST API depth limitation)
|
|
2367
2779
|
lines.push("```");
|
|
2368
|
-
lines.push(
|
|
2780
|
+
lines.push(buildAnatomyTree(structures[0].node));
|
|
2369
2781
|
lines.push("```");
|
|
2370
2782
|
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
2783
|
}
|
|
2373
|
-
|
|
2784
|
+
// Say so when the fetch may have cut the tree off, rather than presenting an
|
|
2785
|
+
// incomplete document as a complete one.
|
|
2786
|
+
const truncated = countPossiblyTruncated(node, fetchDepth);
|
|
2787
|
+
if (truncated > 0) {
|
|
2788
|
+
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._`);
|
|
2789
|
+
lines.push("");
|
|
2790
|
+
}
|
|
2374
2791
|
return lines.join("\n");
|
|
2375
2792
|
}
|
|
2376
|
-
function generateTypographySection(node) {
|
|
2793
|
+
export function generateTypographySection(node) {
|
|
2794
|
+
if (node.type === "COMPONENT_SET" && node.children?.length > 1) {
|
|
2795
|
+
const rows = collectTypographyAcrossVariants(node);
|
|
2796
|
+
if (rows.length === 0)
|
|
2797
|
+
return "";
|
|
2798
|
+
const out = ["", "## Typography", ""];
|
|
2799
|
+
const scoped = rows.some((r) => r.scope);
|
|
2800
|
+
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"}._`);
|
|
2801
|
+
out.push("");
|
|
2802
|
+
out.push(`| Element | Font | Weight | Size | Line Height | Letter Spacing |${scoped ? " Applies to |" : ""}`);
|
|
2803
|
+
out.push(`|---------|------|--------|------|-------------|----------------|${scoped ? "------------|" : ""}`);
|
|
2804
|
+
for (const { style: ts, scope } of rows) {
|
|
2805
|
+
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"} |` : ""}`);
|
|
2806
|
+
}
|
|
2807
|
+
out.push("");
|
|
2808
|
+
return out.join("\n");
|
|
2809
|
+
}
|
|
2377
2810
|
const textStyles = collectTypographyData(node);
|
|
2378
2811
|
if (textStyles.length === 0)
|
|
2379
2812
|
return "";
|
|
@@ -2383,11 +2816,11 @@ function generateTypographySection(node) {
|
|
|
2383
2816
|
// Deduplicate by font properties
|
|
2384
2817
|
const seen = new Set();
|
|
2385
2818
|
for (const ts of textStyles) {
|
|
2386
|
-
const key = `${ts.fontFamily}:${ts.fontWeight}:${ts.fontSize}:${ts.lineHeight}`;
|
|
2819
|
+
const key = `${ts.fontFamily}:${ts.fontWeight}:${ts.fontSize}:${ts.lineHeight}:${ts.hidden ? "h" : ""}:${ts.mixed ? "m" : ""}`;
|
|
2387
2820
|
if (seen.has(key))
|
|
2388
2821
|
continue;
|
|
2389
2822
|
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`} |`);
|
|
2823
|
+
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
2824
|
}
|
|
2392
2825
|
lines.push("");
|
|
2393
2826
|
return lines.join("\n");
|
|
@@ -2412,59 +2845,63 @@ function generateContentGuidelinesSection(parsedDesc) {
|
|
|
2412
2845
|
}
|
|
2413
2846
|
return lines.join("\n");
|
|
2414
2847
|
}
|
|
2415
|
-
function generateParitySection(node, codeInfo) {
|
|
2848
|
+
export function generateParitySection(node, codeInfo) {
|
|
2416
2849
|
const lines = ["", "## Design-Code Parity", ""];
|
|
2417
|
-
// Variant coverage
|
|
2418
|
-
//
|
|
2419
|
-
|
|
2850
|
+
// Variant coverage — compare each Figma variant PROPERTY with the code prop of
|
|
2851
|
+
// the same name, value by value. (This used to look only for a property
|
|
2852
|
+
// literally named "Variant" and otherwise captured the first property's NAME —
|
|
2853
|
+
// so a Size × State set was reported as having a Figma-only variant "Size".)
|
|
2854
|
+
const normalize = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
2855
|
+
const figmaProps = new Map();
|
|
2420
2856
|
if (node.type === "COMPONENT_SET" && node.children) {
|
|
2421
2857
|
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
|
-
}
|
|
2858
|
+
for (const [propName, value] of parseVariantProperties(child.name || "")) {
|
|
2859
|
+
const key = normalize(propName);
|
|
2860
|
+
if (!figmaProps.has(key))
|
|
2861
|
+
figmaProps.set(key, { name: propName, values: new Map() });
|
|
2862
|
+
figmaProps.get(key).values.set(value.toLowerCase(), value);
|
|
2441
2863
|
}
|
|
2442
2864
|
}
|
|
2443
2865
|
}
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2866
|
+
// Code props whose type is a union of string literals: "sm" | "md" | 'lg'
|
|
2867
|
+
const codeProps = new Map();
|
|
2868
|
+
for (const prop of codeInfo.props ?? []) {
|
|
2869
|
+
const literals = prop.type?.match(/["']([^"']+)["']/g);
|
|
2870
|
+
if (!literals)
|
|
2871
|
+
continue;
|
|
2872
|
+
const values = new Map();
|
|
2873
|
+
for (const m of literals) {
|
|
2874
|
+
const raw = m.replace(/["']/g, "");
|
|
2875
|
+
values.set(raw.toLowerCase(), raw);
|
|
2876
|
+
}
|
|
2877
|
+
codeProps.set(normalize(prop.name), { name: prop.name, values });
|
|
2878
|
+
}
|
|
2879
|
+
// Compare properties present on BOTH sides, plus "variant" when either side has
|
|
2880
|
+
// it (the long-standing behavior). A property with no counterpart gets no
|
|
2881
|
+
// claim at all — a boolean in code and a True/False variant in Figma aren't comparable here.
|
|
2882
|
+
const compared = [...figmaProps.keys()].filter((k) => codeProps.has(k));
|
|
2883
|
+
if (!compared.includes("variant") && (figmaProps.has("variant") || codeProps.has("variant")))
|
|
2884
|
+
compared.push("variant");
|
|
2885
|
+
if (compared.length > 0) {
|
|
2886
|
+
const labelProperty = compared.length > 1 || compared[0] !== "variant";
|
|
2447
2887
|
lines.push("### Variant Coverage");
|
|
2448
2888
|
lines.push("");
|
|
2449
2889
|
lines.push("| Variant | In Figma | In Code | Status |");
|
|
2450
2890
|
lines.push("|---------|----------|---------|--------|");
|
|
2451
|
-
for (const key of
|
|
2452
|
-
const
|
|
2453
|
-
const
|
|
2454
|
-
const
|
|
2455
|
-
const
|
|
2456
|
-
const
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2891
|
+
for (const key of compared) {
|
|
2892
|
+
const figma = figmaProps.get(key);
|
|
2893
|
+
const code = codeProps.get(key);
|
|
2894
|
+
const propLabel = figma?.name || code?.name || key;
|
|
2895
|
+
const allValues = new Set([...(figma?.values.keys() ?? []), ...(code?.values.keys() ?? [])]);
|
|
2896
|
+
for (const valueKey of allValues) {
|
|
2897
|
+
const inFigma = figma?.values.has(valueKey) ?? false;
|
|
2898
|
+
const inCode = code?.values.has(valueKey) ?? false;
|
|
2899
|
+
const display = figma?.values.get(valueKey) || code?.values.get(valueKey) || valueKey;
|
|
2900
|
+
const status = inFigma && inCode
|
|
2901
|
+
? "In sync"
|
|
2902
|
+
: inFigma ? "Figma-only — needs code variant" : "Code-only — needs Figma variant";
|
|
2903
|
+
lines.push(`| ${labelProperty ? `${propLabel}: ${display}` : display} | ${inFigma ? "Yes" : "**No**"} | ${inCode ? "Yes" : "**No**"} | ${status} |`);
|
|
2463
2904
|
}
|
|
2464
|
-
else {
|
|
2465
|
-
status = "Code-only — needs Figma variant";
|
|
2466
|
-
}
|
|
2467
|
-
lines.push(`| ${displayName} | ${inFigma ? "Yes" : "**No**"} | ${inCode ? "Yes" : "**No**"} | ${status} |`);
|
|
2468
2905
|
}
|
|
2469
2906
|
lines.push("");
|
|
2470
2907
|
}
|
|
@@ -2917,7 +3354,22 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
|
|
|
2917
3354
|
logger.info({ fileKey, nodeId, enrich }, "Generating component documentation");
|
|
2918
3355
|
const api = await getFigmaAPI();
|
|
2919
3356
|
// Fetch component node with deeper depth for anatomy & per-variant data
|
|
2920
|
-
|
|
3357
|
+
// DOC_TREE_DEPTH, not 4: levels count from the requested node, so on a
|
|
3358
|
+
// COMPONENT_SET depth 4 left each variant only 3 — enough to silently drop
|
|
3359
|
+
// nested labels and a whole scroll mechanism from the generated document.
|
|
3360
|
+
let fetchDepth = DOC_TREE_DEPTH;
|
|
3361
|
+
let nodesResponse;
|
|
3362
|
+
try {
|
|
3363
|
+
nodesResponse = await withTimeout(api.getNodes(fileKey, [nodeId], { depth: DOC_TREE_DEPTH }), DEEP_FETCH_TIMEOUT_MS, "deep component fetch");
|
|
3364
|
+
}
|
|
3365
|
+
catch (deepErr) {
|
|
3366
|
+
// A huge set (hundreds of variants × nested instances) can make the deep
|
|
3367
|
+
// fetch too slow or too large. A shallower document that SAYS it is
|
|
3368
|
+
// shallow beats no document — the truncation note below reports it.
|
|
3369
|
+
fetchDepth = SHALLOW_FETCH_DEPTH;
|
|
3370
|
+
logger.warn({ err: deepErr instanceof Error ? deepErr.message : String(deepErr) }, "Deep fetch failed; retrying component doc fetch at reduced depth");
|
|
3371
|
+
nodesResponse = await api.getNodes(fileKey, [nodeId], { depth: SHALLOW_FETCH_DEPTH });
|
|
3372
|
+
}
|
|
2921
3373
|
const nodeData = nodesResponse?.nodes?.[nodeId];
|
|
2922
3374
|
if (!nodeData?.document) {
|
|
2923
3375
|
throw new Error(`Node ${nodeId} not found in file ${fileKey}`);
|
|
@@ -3002,13 +3454,15 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
|
|
|
3002
3454
|
if (getDesktopConnector) {
|
|
3003
3455
|
try {
|
|
3004
3456
|
const connector = await getDesktopConnector();
|
|
3005
|
-
|
|
3457
|
+
// Target the file being DOCUMENTED, not whichever file is active:
|
|
3458
|
+
// variable ids are file-local, so the active file's variables either
|
|
3459
|
+
// resolve nothing (every token prints as "—") or, on an id collision,
|
|
3460
|
+
// resolve to the wrong names. Rejects → caught → hex fallback.
|
|
3461
|
+
const varsResult = await connector.getVariables(fileKey);
|
|
3006
3462
|
const varList = varsResult?.variables || varsResult?.result?.variables;
|
|
3007
3463
|
if (Array.isArray(varList)) {
|
|
3008
|
-
for (const
|
|
3009
|
-
|
|
3010
|
-
varNameMap.set(v.id, v.name);
|
|
3011
|
-
}
|
|
3464
|
+
for (const [id, name] of buildVariableNameMap(varList, varsResult?.variableCollections || varsResult?.result?.variableCollections))
|
|
3465
|
+
varNameMap.set(id, name);
|
|
3012
3466
|
logger.info({ count: varList.length }, "Resolved variable names via Desktop Bridge for docs");
|
|
3013
3467
|
}
|
|
3014
3468
|
}
|
|
@@ -3033,7 +3487,7 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
|
|
|
3033
3487
|
if (getDesktopConnector) {
|
|
3034
3488
|
try {
|
|
3035
3489
|
const connector = await getDesktopConnector();
|
|
3036
|
-
const bridgeResult = await connector.getComponentFromPluginUI(nodeId);
|
|
3490
|
+
const bridgeResult = await connector.getComponentFromPluginUI(nodeId, fileKey);
|
|
3037
3491
|
if (bridgeResult.success && bridgeResult.component) {
|
|
3038
3492
|
// Fetch description from bridge if REST API returned empty
|
|
3039
3493
|
if (!description) {
|
|
@@ -3141,7 +3595,7 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
|
|
|
3141
3595
|
includedSections.push("overview");
|
|
3142
3596
|
}
|
|
3143
3597
|
if (s.anatomy) {
|
|
3144
|
-
const anatomySection = generateAnatomySection(node);
|
|
3598
|
+
const anatomySection = generateAnatomySection(node, fetchDepth);
|
|
3145
3599
|
if (anatomySection.trim()) {
|
|
3146
3600
|
parts.push(anatomySection);
|
|
3147
3601
|
includedSections.push("anatomy");
|