figma-console-mcp 1.40.4 → 1.40.6
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/core/design-code-tools.d.ts +49 -3
- package/dist/core/design-code-tools.d.ts.map +1 -1
- package/dist/core/design-code-tools.js +357 -83
- package/dist/core/design-code-tools.js.map +1 -1
- package/dist/core/extended-collections.d.ts +103 -0
- package/dist/core/extended-collections.d.ts.map +1 -0
- package/dist/core/extended-collections.js +211 -0
- package/dist/core/extended-collections.js.map +1 -0
- package/dist/core/figma-api.d.ts.map +1 -1
- package/dist/core/figma-api.js +10 -0
- package/dist/core/figma-api.js.map +1 -1
- package/dist/core/figma-tools.d.ts.map +1 -1
- package/dist/core/figma-tools.js +50 -5
- package/dist/core/figma-tools.js.map +1 -1
- package/dist/core/history/git-history.d.ts +35 -0
- package/dist/core/history/git-history.d.ts.map +1 -1
- package/dist/core/history/git-history.js +104 -5
- package/dist/core/history/git-history.js.map +1 -1
- package/dist/core/history/history-formatter.d.ts +4 -0
- package/dist/core/history/history-formatter.d.ts.map +1 -1
- package/dist/core/history/history-formatter.js +4 -4
- package/dist/core/history/history-formatter.js.map +1 -1
- package/dist/core/tokens-tools.d.ts.map +1 -1
- package/dist/core/tokens-tools.js +34 -2
- package/dist/core/tokens-tools.js.map +1 -1
- package/dist/core/types/design-code.d.ts +2 -0
- package/dist/core/types/design-code.d.ts.map +1 -1
- package/dist/core/websocket-server.d.ts +2 -0
- package/dist/core/websocket-server.d.ts.map +1 -1
- package/dist/core/websocket-server.js +4 -0
- package/dist/core/websocket-server.js.map +1 -1
- package/dist/local.d.ts.map +1 -1
- package/dist/local.js +3 -2
- package/dist/local.js.map +1 -1
- package/package.json +4 -3
- package/dist/cloudflare/apps/design-system-dashboard/scoring/accessibility.js +0 -408
- package/dist/cloudflare/apps/design-system-dashboard/scoring/component-metadata.js +0 -366
- package/dist/cloudflare/apps/design-system-dashboard/scoring/consistency.js +0 -407
- package/dist/cloudflare/apps/design-system-dashboard/scoring/coverage.js +0 -233
- package/dist/cloudflare/apps/design-system-dashboard/scoring/engine.js +0 -92
- package/dist/cloudflare/apps/design-system-dashboard/scoring/naming-semantics.js +0 -342
- package/dist/cloudflare/apps/design-system-dashboard/scoring/remediation.js +0 -168
- package/dist/cloudflare/apps/design-system-dashboard/scoring/token-architecture.js +0 -412
- package/dist/cloudflare/apps/design-system-dashboard/scoring/types.js +0 -40
- package/dist/cloudflare/apps/design-system-dashboard/server.js +0 -346
- package/dist/cloudflare/apps/token-browser/server.js +0 -136
- package/dist/cloudflare/browser-manager.js +0 -157
- package/dist/cloudflare/core/accessibility-tools.js +0 -361
- package/dist/cloudflare/core/annotation-tools.js +0 -230
- package/dist/cloudflare/core/cloud-websocket-connector.js +0 -432
- package/dist/cloudflare/core/cloud-websocket-relay.js +0 -198
- package/dist/cloudflare/core/comment-tools.js +0 -292
- package/dist/cloudflare/core/config.js +0 -153
- package/dist/cloudflare/core/console-monitor.js +0 -427
- package/dist/cloudflare/core/deep-component-tools.js +0 -128
- package/dist/cloudflare/core/design-code-tools.js +0 -3755
- package/dist/cloudflare/core/design-system-extraction-tools.js +0 -611
- package/dist/cloudflare/core/design-system-manifest.js +0 -283
- package/dist/cloudflare/core/design-system-tools.js +0 -882
- package/dist/cloudflare/core/diagnose-tool.js +0 -100
- package/dist/cloudflare/core/diff/changelog-formatter.js +0 -275
- package/dist/cloudflare/core/diff/diff-engine.js +0 -334
- package/dist/cloudflare/core/diff/property-compare.js +0 -36
- package/dist/cloudflare/core/diff/version-cache.js +0 -74
- package/dist/cloudflare/core/enrichment/enrichment-service.js +0 -374
- package/dist/cloudflare/core/enrichment/index.js +0 -7
- package/dist/cloudflare/core/enrichment/relationship-mapper.js +0 -351
- package/dist/cloudflare/core/enrichment/style-resolver.js +0 -346
- package/dist/cloudflare/core/extraction/analyzer.js +0 -161
- package/dist/cloudflare/core/extraction/architecture.js +0 -159
- package/dist/cloudflare/core/extraction/component-inventory.js +0 -485
- package/dist/cloudflare/core/extraction/detectors.js +0 -148
- package/dist/cloudflare/core/extraction/scaffolder.js +0 -614
- package/dist/cloudflare/core/extraction/storybook-preset.js +0 -349
- package/dist/cloudflare/core/extraction/token-extractor.js +0 -759
- package/dist/cloudflare/core/extraction/types.js +0 -14
- package/dist/cloudflare/core/extraction/verifier.js +0 -137
- package/dist/cloudflare/core/extraction/walker.js +0 -118
- package/dist/cloudflare/core/figjam-tools.js +0 -547
- package/dist/cloudflare/core/figma-api.js +0 -509
- package/dist/cloudflare/core/figma-connector.js +0 -7
- package/dist/cloudflare/core/figma-reconstruction-spec.js +0 -402
- package/dist/cloudflare/core/figma-style-extractor.js +0 -311
- package/dist/cloudflare/core/figma-tools.js +0 -3304
- package/dist/cloudflare/core/history/component-history.js +0 -388
- package/dist/cloudflare/core/history/git-history.js +0 -161
- package/dist/cloudflare/core/history/history-formatter.js +0 -116
- package/dist/cloudflare/core/identity.js +0 -143
- package/dist/cloudflare/core/library-tools.js +0 -629
- package/dist/cloudflare/core/logger.js +0 -53
- package/dist/cloudflare/core/multi-file-tools.js +0 -161
- package/dist/cloudflare/core/port-discovery.js +0 -1016
- package/dist/cloudflare/core/resolve-package-root.js +0 -11
- package/dist/cloudflare/core/slides-tools.js +0 -714
- package/dist/cloudflare/core/slot-tools.js +0 -334
- package/dist/cloudflare/core/snippet-injector.js +0 -96
- package/dist/cloudflare/core/tokens/alias-resolver.js +0 -205
- package/dist/cloudflare/core/tokens/config.js +0 -294
- package/dist/cloudflare/core/tokens/dialect.js +0 -232
- package/dist/cloudflare/core/tokens/figma-converter.js +0 -323
- package/dist/cloudflare/core/tokens/formatters/css-vars.js +0 -345
- package/dist/cloudflare/core/tokens/formatters/dtcg.js +0 -376
- package/dist/cloudflare/core/tokens/formatters/index.js +0 -45
- package/dist/cloudflare/core/tokens/formatters/json.js +0 -205
- package/dist/cloudflare/core/tokens/formatters/less.js +0 -4
- package/dist/cloudflare/core/tokens/formatters/scss.js +0 -264
- package/dist/cloudflare/core/tokens/formatters/stubs.js +0 -13
- package/dist/cloudflare/core/tokens/formatters/style-dictionary-v3.js +0 -213
- package/dist/cloudflare/core/tokens/formatters/tailwind-v3.js +0 -237
- package/dist/cloudflare/core/tokens/formatters/tailwind-v4.js +0 -341
- package/dist/cloudflare/core/tokens/formatters/tokens-studio.js +0 -256
- package/dist/cloudflare/core/tokens/formatters/ts-module.js +0 -198
- package/dist/cloudflare/core/tokens/index.js +0 -16
- package/dist/cloudflare/core/tokens/parsers/css-vars.js +0 -4
- package/dist/cloudflare/core/tokens/parsers/dtcg.js +0 -280
- package/dist/cloudflare/core/tokens/parsers/index.js +0 -138
- package/dist/cloudflare/core/tokens/parsers/json.js +0 -7
- package/dist/cloudflare/core/tokens/parsers/scss.js +0 -4
- package/dist/cloudflare/core/tokens/parsers/stubs.js +0 -20
- package/dist/cloudflare/core/tokens/parsers/style-dictionary-v3.js +0 -4
- package/dist/cloudflare/core/tokens/parsers/tailwind-v3.js +0 -4
- package/dist/cloudflare/core/tokens/parsers/tailwind-v4.js +0 -4
- package/dist/cloudflare/core/tokens/parsers/tokens-studio.js +0 -4
- package/dist/cloudflare/core/tokens/schemas.js +0 -152
- package/dist/cloudflare/core/tokens/transforms/color.js +0 -12
- package/dist/cloudflare/core/tokens/transforms/index.js +0 -29
- package/dist/cloudflare/core/tokens/transforms/size.js +0 -7
- package/dist/cloudflare/core/tokens/types.js +0 -18
- package/dist/cloudflare/core/tokens-tools.js +0 -2238
- package/dist/cloudflare/core/types/design-code.js +0 -4
- package/dist/cloudflare/core/types/enriched.js +0 -5
- package/dist/cloudflare/core/types/index.js +0 -4
- package/dist/cloudflare/core/variable-resolver.js +0 -85
- package/dist/cloudflare/core/version-tools.js +0 -1199
- package/dist/cloudflare/core/websocket-connector.js +0 -439
- package/dist/cloudflare/core/websocket-server.js +0 -1050
- package/dist/cloudflare/core/write-tools.js +0 -2505
- package/dist/cloudflare/index.js +0 -3010
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
* and generating platform-agnostic component documentation.
|
|
5
5
|
*/
|
|
6
6
|
import { z } from "zod";
|
|
7
|
+
import { basename, dirname, isAbsolute, relative } from "node:path";
|
|
7
8
|
import { extractFileKey, withTimeout } from "./figma-api.js";
|
|
8
9
|
import { createChildLogger } from "./logger.js";
|
|
9
10
|
import { EnrichmentService } from "./enrichment/index.js";
|
|
10
11
|
import { buildDesignHistory, DEFAULT_HISTORY_VERSIONS, MAX_HISTORY_VERSIONS, } from "./history/component-history.js";
|
|
11
|
-
import { buildGitHistory, DEFAULT_GIT_LIMIT, MAX_GIT_LIMIT, } from "./history/git-history.js";
|
|
12
|
+
import { blobUrl, buildGitHistory, commitUrl, DEFAULT_GIT_LIMIT, MAX_GIT_LIMIT, resolveSourceRevision, } from "./history/git-history.js";
|
|
12
13
|
import { formatHistorySection } from "./history/history-formatter.js";
|
|
13
14
|
const logger = createChildLogger({ component: "design-code-tools" });
|
|
14
15
|
const enrichmentService = new EnrichmentService(logger);
|
|
@@ -323,6 +324,44 @@ export function buildVariableNameMap(variables, collections) {
|
|
|
323
324
|
}
|
|
324
325
|
return out;
|
|
325
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* A code path as it may appear in published docs: repo-relative paths as given;
|
|
329
|
+
* absolute local paths made relative to `root` when inside it, else reduced to
|
|
330
|
+
* the file name — never the author's home directory layout.
|
|
331
|
+
*/
|
|
332
|
+
export function displayPath(path, root) {
|
|
333
|
+
if (!isAbsolute(path))
|
|
334
|
+
return path;
|
|
335
|
+
if (root) {
|
|
336
|
+
const rel = relative(root, path);
|
|
337
|
+
if (rel && !rel.startsWith("..") && !isAbsolute(rel))
|
|
338
|
+
return rel;
|
|
339
|
+
}
|
|
340
|
+
return basename(path);
|
|
341
|
+
}
|
|
342
|
+
/** The boolean component property that controls this layer's visibility, if any */
|
|
343
|
+
export function visibilityProperty(node) {
|
|
344
|
+
const ref = node?.componentPropertyReferences?.visible;
|
|
345
|
+
return typeof ref === "string" ? stripPropertyIdSuffix(ref) : null;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* How to label a layer's visibility: "(hidden — shown when Is Focused = true)"
|
|
349
|
+
* for a hidden layer a boolean property reveals, "(toggled by Show Icon)" for a
|
|
350
|
+
* visible one it can hide, "(hidden)" otherwise. Empty for ordinary layers.
|
|
351
|
+
*/
|
|
352
|
+
export function visibilityNote(node) {
|
|
353
|
+
const prop = visibilityProperty(node);
|
|
354
|
+
if (node?.visible === false)
|
|
355
|
+
return prop ? `hidden — shown when ${prop} = true` : "hidden";
|
|
356
|
+
return prop ? `toggled by ${prop}` : "";
|
|
357
|
+
}
|
|
358
|
+
/** "Tab Item (Is Selected=True)" — an instance, qualified by its variant */
|
|
359
|
+
export function describeInstance(node) {
|
|
360
|
+
const variantProps = Object.entries(node?.componentProperties ?? {})
|
|
361
|
+
.filter(([, p]) => p?.type === "VARIANT")
|
|
362
|
+
.map(([k, p]) => `${stripPropertyIdSuffix(k)}=${p.value}`);
|
|
363
|
+
return variantProps.length > 0 ? `${node.name} (${variantProps.join(", ")})` : node?.name ?? "instance";
|
|
364
|
+
}
|
|
326
365
|
/** Strip Figma's internal "#123:4" id suffix from a component property name */
|
|
327
366
|
function stripPropertyIdSuffix(rawName) {
|
|
328
367
|
return rawName.replace(/#\d+:\d+$/, "").trim();
|
|
@@ -506,13 +545,20 @@ function walkVariantNode(node, data, ctx, depth, maxDepth,
|
|
|
506
545
|
/** Label of the icon instance we're inside of, or null when outside any icon */
|
|
507
546
|
insideIcon,
|
|
508
547
|
/** An ancestor is hidden, so nothing beneath it paints either */
|
|
509
|
-
ancestorHidden = false
|
|
548
|
+
ancestorHidden = false,
|
|
549
|
+
/** Property that shows the nearest hidden ancestor, when one does */
|
|
550
|
+
ancestorShownWhen = null,
|
|
551
|
+
/** Nearest nested instance ("Tab Item (Is Selected=True)") */
|
|
552
|
+
owner = null) {
|
|
510
553
|
if (depth > maxDepth)
|
|
511
554
|
return;
|
|
512
555
|
const isText = node.type === "TEXT";
|
|
513
556
|
// 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
|
-
|
|
557
|
+
// intent. They are labeled so a reader can tell it from what actually renders —
|
|
558
|
+
// and, when a boolean property reveals them, with the property that does.
|
|
559
|
+
const hiddenHere = node.visible === false;
|
|
560
|
+
const hidden = ancestorHidden || hiddenHere;
|
|
561
|
+
const shownWhen = hiddenHere ? visibilityProperty(node) : ancestorShownWhen;
|
|
516
562
|
// Icons are reported once, at the outermost instance — everything beneath it
|
|
517
563
|
// (vectors, nested instances) is that icon's artwork, not a separate icon.
|
|
518
564
|
if (insideIcon === null) {
|
|
@@ -531,6 +577,8 @@ ancestorHidden = false) {
|
|
|
531
577
|
variableId: varId,
|
|
532
578
|
variableName: varId ? ctx.varNameMap.get(varId) : undefined,
|
|
533
579
|
...(hidden ? { hidden: true } : {}),
|
|
580
|
+
...(hidden && shownWhen ? { shownWhen } : {}),
|
|
581
|
+
...(owner ? { owner } : {}),
|
|
534
582
|
};
|
|
535
583
|
};
|
|
536
584
|
// Collect fills. Only the variant root (or its full-bleed stand-in) is a
|
|
@@ -591,14 +639,14 @@ ancestorHidden = false) {
|
|
|
591
639
|
// Recurse into children
|
|
592
640
|
if (node.children && Array.isArray(node.children)) {
|
|
593
641
|
for (const child of node.children) {
|
|
594
|
-
walkVariantNode(child, data, ctx, depth + 1, maxDepth, insideIcon, hidden);
|
|
642
|
+
walkVariantNode(child, data, ctx, depth + 1, maxDepth, insideIcon, hidden, shownWhen, node.type === "INSTANCE" && depth > 0 ? describeInstance(node) : owner);
|
|
595
643
|
}
|
|
596
644
|
}
|
|
597
645
|
}
|
|
598
646
|
/**
|
|
599
647
|
* Collect typography data from all text nodes in a component tree.
|
|
600
648
|
*/
|
|
601
|
-
export function collectTypographyData(node, depth = 0, maxDepth = DOC_TREE_DEPTH, ancestorHidden = false) {
|
|
649
|
+
export function collectTypographyData(node, depth = 0, maxDepth = DOC_TREE_DEPTH, ancestorHidden = false, owner = null) {
|
|
602
650
|
const results = [];
|
|
603
651
|
if (depth > maxDepth)
|
|
604
652
|
return results;
|
|
@@ -624,6 +672,7 @@ export function collectTypographyData(node, depth = 0, maxDepth = DOC_TREE_DEPTH
|
|
|
624
672
|
lineHeight: s.lineHeightPx || s.fontSize || 14,
|
|
625
673
|
letterSpacing: s.letterSpacing || 0,
|
|
626
674
|
...(hidden ? { hidden: true } : {}),
|
|
675
|
+
...(owner ? { owner } : {}),
|
|
627
676
|
// REST: characterStyleOverrides maps characters → styleOverrideTable ids; any
|
|
628
677
|
// non-zero entry means part of the text departs from `style`
|
|
629
678
|
...(Array.isArray(node.characterStyleOverrides) && node.characterStyleOverrides.some((id) => id !== 0)
|
|
@@ -632,7 +681,7 @@ export function collectTypographyData(node, depth = 0, maxDepth = DOC_TREE_DEPTH
|
|
|
632
681
|
}
|
|
633
682
|
if (node.children && Array.isArray(node.children)) {
|
|
634
683
|
for (const child of node.children) {
|
|
635
|
-
results.push(...collectTypographyData(child, depth + 1, maxDepth, hidden));
|
|
684
|
+
results.push(...collectTypographyData(child, depth + 1, maxDepth, hidden, node.type === "INSTANCE" && depth > 0 ? describeInstance(node) : owner));
|
|
636
685
|
}
|
|
637
686
|
}
|
|
638
687
|
return results;
|
|
@@ -668,6 +717,13 @@ export function collectTypographyAcrossVariants(setNode) {
|
|
|
668
717
|
}
|
|
669
718
|
}
|
|
670
719
|
}
|
|
720
|
+
// An element name shared by rows with DIFFERENT styles (the selected tab's
|
|
721
|
+
// SemiBold Label vs the others' Regular Label) is qualified by its instance.
|
|
722
|
+
const stylesPerName = new Map();
|
|
723
|
+
for (const key of order) {
|
|
724
|
+
const n = styles.get(key).nodeName;
|
|
725
|
+
stylesPerName.set(n, (stylesPerName.get(n) ?? 0) + 1);
|
|
726
|
+
}
|
|
671
727
|
return order.map((key) => {
|
|
672
728
|
const style = styles.get(key);
|
|
673
729
|
const having = perVariant.filter((pv) => pv.keys.has(key));
|
|
@@ -681,7 +737,8 @@ export function collectTypographyAcrossVariants(setNode) {
|
|
|
681
737
|
: scopeLabel(having.map((pv) => pv.name), perVariant.length);
|
|
682
738
|
}
|
|
683
739
|
// Text layers are often auto-named after their content — keep the table readable
|
|
684
|
-
const
|
|
740
|
+
const base = style.nodeName.length > 40 ? `${style.nodeName.slice(0, 40)}…` : style.nodeName;
|
|
741
|
+
const label = (stylesPerName.get(style.nodeName) ?? 0) > 1 && style.owner ? `${base} in ${style.owner}` : base;
|
|
685
742
|
return { style: { ...style, nodeName: label }, scope };
|
|
686
743
|
});
|
|
687
744
|
}
|
|
@@ -774,7 +831,8 @@ suffix = "") {
|
|
|
774
831
|
}
|
|
775
832
|
// Hidden layers stay in the tree, marked: the color table names them (a hidden
|
|
776
833
|
// focus ring is real design intent), so the tree must not pretend they don't exist.
|
|
777
|
-
const
|
|
834
|
+
const note = visibilityNote(node);
|
|
835
|
+
const hiddenHint = note ? ` (${note})` : "";
|
|
778
836
|
lines.push(`${prefix}${connector}${label}${typeHint}${suffix}${hiddenHint}${layoutInfo}${sizingInfo}`);
|
|
779
837
|
// Recurse into children, collapsing RUNS of siblings that would print
|
|
780
838
|
// identically (ten nav items, five list rows) into one entry marked ×N. Compared
|
|
@@ -800,18 +858,89 @@ suffix = "") {
|
|
|
800
858
|
}
|
|
801
859
|
}
|
|
802
860
|
/**
|
|
803
|
-
*
|
|
861
|
+
* The variable a spacing-table property is bound to, read from where Figma
|
|
862
|
+
* actually stores the binding. Radius and per-side stroke weights are bound PER
|
|
863
|
+
* CORNER / PER SIDE (`topLeftRadius`, `strokeBottomWeight`, …) — there is never a
|
|
864
|
+
* `cornerRadius` key in `boundVariables`, even when all four corners share one
|
|
865
|
+
* token — so a lookup by the row's own key reported every radius token as
|
|
866
|
+
* hardcoded.
|
|
867
|
+
*
|
|
868
|
+
* `name` is the display text for the Figma Variable column (undefined = unbound);
|
|
869
|
+
* `signature` identifies the binding for cross-variant comparison.
|
|
804
870
|
*/
|
|
871
|
+
export function resolvePropertyBinding(node, key, varNameMap = new Map()) {
|
|
872
|
+
const bound = node?.boundVariables ?? {};
|
|
873
|
+
// The same binding has two shapes. Plugin API: flat keys (`topLeftRadius`,
|
|
874
|
+
// `strokeBottomWeight`). REST — which this tool reads — nests them:
|
|
875
|
+
// `rectangleCornerRadii.RECTANGLE_TOP_LEFT_CORNER_RADIUS`,
|
|
876
|
+
// `individualStrokeWeights.BORDER_BOTTOM_WEIGHT` (verified against JSON_REST_V1).
|
|
877
|
+
const REST_NESTED = {
|
|
878
|
+
topLeftRadius: ["rectangleCornerRadii", "RECTANGLE_TOP_LEFT_CORNER_RADIUS"],
|
|
879
|
+
topRightRadius: ["rectangleCornerRadii", "RECTANGLE_TOP_RIGHT_CORNER_RADIUS"],
|
|
880
|
+
bottomRightRadius: ["rectangleCornerRadii", "RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS"],
|
|
881
|
+
bottomLeftRadius: ["rectangleCornerRadii", "RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS"],
|
|
882
|
+
strokeTopWeight: ["individualStrokeWeights", "BORDER_TOP_WEIGHT"],
|
|
883
|
+
strokeRightWeight: ["individualStrokeWeights", "BORDER_RIGHT_WEIGHT"],
|
|
884
|
+
strokeBottomWeight: ["individualStrokeWeights", "BORDER_BOTTOM_WEIGHT"],
|
|
885
|
+
strokeLeftWeight: ["individualStrokeWeights", "BORDER_LEFT_WEIGHT"],
|
|
886
|
+
};
|
|
887
|
+
const idOf = (k) => {
|
|
888
|
+
let b = bound[k];
|
|
889
|
+
if (b === undefined && REST_NESTED[k])
|
|
890
|
+
b = bound[REST_NESTED[k][0]]?.[REST_NESTED[k][1]];
|
|
891
|
+
const id = Array.isArray(b) ? b[0]?.id : b?.id;
|
|
892
|
+
return typeof id === "string" ? id : undefined;
|
|
893
|
+
};
|
|
894
|
+
const nameOf = (id) => varNameMap.get(id) || id;
|
|
895
|
+
let parts = null;
|
|
896
|
+
if (key === "cornerRadius") {
|
|
897
|
+
if (idOf("cornerRadius"))
|
|
898
|
+
parts = null;
|
|
899
|
+
else
|
|
900
|
+
parts = [
|
|
901
|
+
["top-left", idOf("topLeftRadius")], ["top-right", idOf("topRightRadius")],
|
|
902
|
+
["bottom-right", idOf("bottomRightRadius")], ["bottom-left", idOf("bottomLeftRadius")],
|
|
903
|
+
];
|
|
904
|
+
}
|
|
905
|
+
else if (key === "strokeWeight" && !idOf("strokeWeight")) {
|
|
906
|
+
const sides = node?.individualStrokeWeights;
|
|
907
|
+
const all = [
|
|
908
|
+
["top", "strokeTopWeight", sides?.top ?? 1], ["right", "strokeRightWeight", sides?.right ?? 1],
|
|
909
|
+
["bottom", "strokeBottomWeight", sides?.bottom ?? 1], ["left", "strokeLeftWeight", sides?.left ?? 1],
|
|
910
|
+
];
|
|
911
|
+
// Only sides that actually paint a border say anything about it
|
|
912
|
+
parts = all.filter(([, , w]) => w > 0).map(([side, k]) => [side, idOf(k)]);
|
|
913
|
+
}
|
|
914
|
+
if (!parts) {
|
|
915
|
+
const id = idOf(key);
|
|
916
|
+
return id ? { name: nameOf(id), signature: id } : { signature: "" };
|
|
917
|
+
}
|
|
918
|
+
const ids = parts.map(([, id]) => id);
|
|
919
|
+
const boundIds = ids.filter((id) => !!id);
|
|
920
|
+
if (boundIds.length === 0)
|
|
921
|
+
return { signature: "" };
|
|
922
|
+
const signature = parts.map(([p, id]) => `${p}:${id ?? ""}`).join(",");
|
|
923
|
+
if (boundIds.length === ids.length && new Set(boundIds).size === 1) {
|
|
924
|
+
return { name: nameOf(boundIds[0]), signature: boundIds[0] };
|
|
925
|
+
}
|
|
926
|
+
if (new Set(boundIds).size === 1) {
|
|
927
|
+
const on = parts.filter(([, id]) => id).map(([p]) => p);
|
|
928
|
+
return { name: `${nameOf(boundIds[0])} (${on.join(", ")} only)`, signature };
|
|
929
|
+
}
|
|
930
|
+
return { name: parts.map(([p, id]) => `${p}: ${id ? nameOf(id) : "unbound"}`).join(", "), signature };
|
|
931
|
+
}
|
|
932
|
+
/** Collect spacing tokens with their bound variable names. */
|
|
805
933
|
function collectSpacingTokens(node, varNameMap = new Map()) {
|
|
806
934
|
const tokens = [];
|
|
807
935
|
const autoLayout = !!node.layoutMode && node.layoutMode !== "NONE";
|
|
808
|
-
const boundVars = node.boundVariables || {};
|
|
809
936
|
const spacingProps = [
|
|
810
937
|
{ key: "paddingTop", label: "Padding top" },
|
|
811
938
|
{ key: "paddingRight", label: "Padding right" },
|
|
812
939
|
{ key: "paddingBottom", label: "Padding bottom" },
|
|
813
940
|
{ key: "paddingLeft", label: "Padding left" },
|
|
814
941
|
{ key: "itemSpacing", label: "Gap" },
|
|
942
|
+
// Cross-axis gap between wrapped rows (auto-layout WRAP); bound as its own key
|
|
943
|
+
{ key: "counterAxisSpacing", label: "Row gap (wrap)" },
|
|
815
944
|
{ key: "cornerRadius", label: "Border radius" },
|
|
816
945
|
{ key: "strokeWeight", label: "Border width" },
|
|
817
946
|
];
|
|
@@ -827,16 +956,10 @@ function collectSpacingTokens(node, varNameMap = new Map()) {
|
|
|
827
956
|
value = node.rectangleCornerRadii.map((r) => `${r}px`).join(" / ");
|
|
828
957
|
}
|
|
829
958
|
if (value !== undefined && value !== null) {
|
|
830
|
-
const varBinding = boundVars[key];
|
|
831
|
-
// Bound spacing variables expose their id as boundVariables[key].id.
|
|
832
|
-
// Resolve it to a friendly token name (e.g. `spacing/1`) when the caller
|
|
833
|
-
// supplied a name map; fall back to the raw id so the binding stays visible.
|
|
834
|
-
const varId = typeof varBinding?.id === "string" ? varBinding.id : undefined;
|
|
835
|
-
const varName = varId ? varNameMap.get(varId) || varId : undefined;
|
|
836
959
|
tokens.push({
|
|
837
960
|
property: label,
|
|
838
961
|
value,
|
|
839
|
-
variableName:
|
|
962
|
+
variableName: resolvePropertyBinding(node, key, varNameMap).name,
|
|
840
963
|
});
|
|
841
964
|
}
|
|
842
965
|
}
|
|
@@ -920,6 +1043,8 @@ const SPACING_COMPARISON_PROPS = [
|
|
|
920
1043
|
{ key: "paddingBottom", label: "Padding bottom" },
|
|
921
1044
|
{ key: "paddingLeft", label: "Padding left" },
|
|
922
1045
|
{ key: "itemSpacing", label: "Gap" },
|
|
1046
|
+
// Cross-axis gap between wrapped rows (auto-layout WRAP); bound as its own key
|
|
1047
|
+
{ key: "counterAxisSpacing", label: "Row gap (wrap)" },
|
|
923
1048
|
{ key: "cornerRadius", label: "Border radius" },
|
|
924
1049
|
{ key: "strokeWeight", label: "Border width" },
|
|
925
1050
|
];
|
|
@@ -971,15 +1096,13 @@ export function collectSpacingAcrossVariants(setNode, varNameMap = new Map()) {
|
|
|
971
1096
|
}
|
|
972
1097
|
if (key === "strokeWeight")
|
|
973
1098
|
value = describeStrokeWeight(v);
|
|
974
|
-
const
|
|
975
|
-
? v.boundVariables[key].id
|
|
976
|
-
: undefined;
|
|
1099
|
+
const binding = resolvePropertyBinding(v, key, varNameMap);
|
|
977
1100
|
return {
|
|
978
1101
|
displayName: cleanVariantName(v.name || "Unknown"),
|
|
979
1102
|
props: parseVariantProperties(v.name || ""),
|
|
980
1103
|
value,
|
|
981
|
-
variableName:
|
|
982
|
-
signature: `${value}|${
|
|
1104
|
+
variableName: binding.name,
|
|
1105
|
+
signature: `${value}|${binding.signature}`,
|
|
983
1106
|
display: "",
|
|
984
1107
|
};
|
|
985
1108
|
});
|
|
@@ -1984,7 +2107,11 @@ export function generateFrontmatter(componentName, description, node, componentM
|
|
|
1984
2107
|
* pulled. Emitted as its own `figmaVersion` field rather than folded into
|
|
1985
2108
|
* `version` — that one is the code-side semver and means something different.
|
|
1986
2109
|
*/
|
|
1987
|
-
latestDesignVersion
|
|
2110
|
+
latestDesignVersion,
|
|
2111
|
+
/** The commit the code-side information was read at (local mode, git repos only) */
|
|
2112
|
+
sourceRevision,
|
|
2113
|
+
/** Directory absolute code paths are shown relative to */
|
|
2114
|
+
sourceRoot) {
|
|
1988
2115
|
// Only state what is actually known. "stable" / "1.0.0" used to be emitted for
|
|
1989
2116
|
// every component and read as extracted facts.
|
|
1990
2117
|
const status = componentMeta?.description?.toLowerCase().includes("deprecated")
|
|
@@ -2010,7 +2137,7 @@ latestDesignVersion) {
|
|
|
2010
2137
|
`figma: ${fileUrl}`,
|
|
2011
2138
|
];
|
|
2012
2139
|
if (codeInfo?.filePath) {
|
|
2013
|
-
lines.push(`source: ${codeInfo.filePath}`);
|
|
2140
|
+
lines.push(`source: ${displayPath(codeInfo.filePath, sourceRoot)}`);
|
|
2014
2141
|
}
|
|
2015
2142
|
if (codeInfo?.packageName) {
|
|
2016
2143
|
lines.push(`package: ${codeInfo.packageName}`);
|
|
@@ -2018,6 +2145,15 @@ latestDesignVersion) {
|
|
|
2018
2145
|
if (canonicalSource) {
|
|
2019
2146
|
lines.push(`canonical: ${canonicalSource}`);
|
|
2020
2147
|
}
|
|
2148
|
+
if (sourceRevision) {
|
|
2149
|
+
// Pins the code side of this page: without it nothing says whether the
|
|
2150
|
+
// code it describes is still current.
|
|
2151
|
+
lines.push(`sourceCommit: ${sourceRevision.commit}`);
|
|
2152
|
+
if (sourceRevision.dirty)
|
|
2153
|
+
lines.push("sourceDirty: true # documented files had uncommitted changes");
|
|
2154
|
+
if (sourceRevision.webBase)
|
|
2155
|
+
lines.push(`sourceRepository: ${sourceRevision.webBase}`);
|
|
2156
|
+
}
|
|
2021
2157
|
if (latestDesignVersion) {
|
|
2022
2158
|
// Quote the label — it's user-authored and may contain YAML-significant
|
|
2023
2159
|
// characters (a colon in "v2: buttons" would otherwise break parsing).
|
|
@@ -2035,20 +2171,34 @@ latestDesignVersion) {
|
|
|
2035
2171
|
lines.push("---");
|
|
2036
2172
|
return lines.join("\n");
|
|
2037
2173
|
}
|
|
2038
|
-
export function generateOverviewSection(componentName, description, fileUrl, parsedDesc, codeInfo
|
|
2174
|
+
export function generateOverviewSection(componentName, description, fileUrl, parsedDesc, codeInfo,
|
|
2175
|
+
/** Stable web URL for a code path (pinned to a commit), when one can be resolved */
|
|
2176
|
+
sourceUrl,
|
|
2177
|
+
/** Directory absolute paths are shown relative to */
|
|
2178
|
+
sourceRoot) {
|
|
2039
2179
|
const lines = [
|
|
2040
2180
|
`# ${componentName}`,
|
|
2041
2181
|
"",
|
|
2042
2182
|
];
|
|
2043
|
-
//
|
|
2183
|
+
// Never publish an absolute local path (it discloses the author's username and
|
|
2184
|
+
// directory layout) — show it relative to the repo when known, else the file name.
|
|
2185
|
+
const shown = (p) => displayPath(p, sourceRoot);
|
|
2186
|
+
// Build links line. A raw repo-relative path is NOT a link — it resolves to
|
|
2187
|
+
// nothing once the page is published anywhere else — so link only what
|
|
2188
|
+
// resolves to a stable URL, and show anything else as a path.
|
|
2044
2189
|
const links = [`**[Open in Figma](${fileUrl})**`];
|
|
2045
2190
|
if (codeInfo?.filePath) {
|
|
2046
|
-
|
|
2191
|
+
const url = sourceUrl?.(codeInfo.filePath);
|
|
2192
|
+
links.push(url ? `**[View Source](${url})**` : `Source: \`${shown(codeInfo.filePath)}\``);
|
|
2047
2193
|
}
|
|
2048
|
-
|
|
2194
|
+
if (codeInfo?.storybookUrl) {
|
|
2195
|
+
links.push(`**[Storybook](${codeInfo.storybookUrl})**`);
|
|
2196
|
+
}
|
|
2197
|
+
// A stories FILE is the stories' source, not a running Storybook — say so
|
|
2049
2198
|
const storiesFile = codeInfo?.sourceFiles?.find((f) => f.role.toLowerCase().includes("storybook") || f.role.toLowerCase().includes("stories") || f.path.includes(".stories."));
|
|
2050
2199
|
if (storiesFile) {
|
|
2051
|
-
|
|
2200
|
+
const url = sourceUrl?.(storiesFile.path);
|
|
2201
|
+
links.push(url ? `**[Stories source](${url})**` : `Stories source: \`${shown(storiesFile.path)}\``);
|
|
2052
2202
|
}
|
|
2053
2203
|
lines.push(links.join(" | "));
|
|
2054
2204
|
lines.push("");
|
|
@@ -2144,6 +2294,24 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
|
|
|
2144
2294
|
const lines = ["", "## Variants", ""];
|
|
2145
2295
|
const variants = [];
|
|
2146
2296
|
const booleans = [];
|
|
2297
|
+
// Which layers each boolean property shows/hides — read from the layers' own
|
|
2298
|
+
// componentPropertyReferences.visible. Nested instances are not descended
|
|
2299
|
+
// into: their references point at THEIR component's properties, not ours.
|
|
2300
|
+
const layersByBoolean = new Map();
|
|
2301
|
+
const collectVisibilityRefs = (n, isRoot) => {
|
|
2302
|
+
const ref = n?.componentPropertyReferences?.visible;
|
|
2303
|
+
if (!isRoot && typeof ref === "string") {
|
|
2304
|
+
if (!layersByBoolean.has(ref))
|
|
2305
|
+
layersByBoolean.set(ref, new Set());
|
|
2306
|
+
layersByBoolean.get(ref).add(n.name);
|
|
2307
|
+
}
|
|
2308
|
+
if (!isRoot && n?.type === "INSTANCE")
|
|
2309
|
+
return;
|
|
2310
|
+
for (const c of n?.children ?? [])
|
|
2311
|
+
collectVisibilityRefs(c, false);
|
|
2312
|
+
};
|
|
2313
|
+
for (const root of node.type === "COMPONENT_SET" ? node.children ?? [] : [node])
|
|
2314
|
+
collectVisibilityRefs(root, true);
|
|
2147
2315
|
const textProps = [];
|
|
2148
2316
|
const instanceSwaps = [];
|
|
2149
2317
|
const slots = [];
|
|
@@ -2158,7 +2326,7 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
|
|
|
2158
2326
|
});
|
|
2159
2327
|
}
|
|
2160
2328
|
else if (def.type === "BOOLEAN") {
|
|
2161
|
-
booleans.push({ name, defaultValue: def.defaultValue ?? true });
|
|
2329
|
+
booleans.push({ name, defaultValue: def.defaultValue ?? true, controls: [...(layersByBoolean.get(rawName) ?? [])] });
|
|
2162
2330
|
}
|
|
2163
2331
|
else if (def.type === "TEXT") {
|
|
2164
2332
|
textProps.push({ name, defaultValue: def.defaultValue || "" });
|
|
@@ -2257,7 +2425,12 @@ export function generateStatesAndVariantsSection(node, variantData, lookup = {})
|
|
|
2257
2425
|
lines.push(`| **${v.name}** | \`${v.values.map((val) => `"${val}"`).join(" \\| ")}\` | \`"${v.defaultValue}"\` | Changes visual treatment |`);
|
|
2258
2426
|
}
|
|
2259
2427
|
for (const b of booleans) {
|
|
2260
|
-
|
|
2428
|
+
// Describe the property by the layer it actually controls; never invent an
|
|
2429
|
+
// element from the property's name ("Is Focused" → "is focused element")
|
|
2430
|
+
const what = b.controls.length > 0
|
|
2431
|
+
? `Shows/hides ${b.controls.map((l) => `**${l}**`).join(", ")}`
|
|
2432
|
+
: "Boolean toggle";
|
|
2433
|
+
lines.push(`| **${b.name}** | \`boolean\` | \`${b.defaultValue}\` | ${what} |`);
|
|
2261
2434
|
}
|
|
2262
2435
|
for (const t of textProps) {
|
|
2263
2436
|
lines.push(`| **${t.name}** | \`string\` | \`"${t.defaultValue}"\` | Sets ${t.name.toLowerCase()} content |`);
|
|
@@ -2368,7 +2541,8 @@ setNode) {
|
|
|
2368
2541
|
// Text layers are often auto-named after their content ("Lorem ipsum dolor…")
|
|
2369
2542
|
const short = (name) => (name.length > 40 ? `${name.slice(0, 40)}…` : name);
|
|
2370
2543
|
const pushRow = (label, c) => {
|
|
2371
|
-
const
|
|
2544
|
+
const visibility = !c.hidden ? "" : c.shownWhen ? ` _(hidden — shown when ${c.shownWhen} = true)_` : " _(hidden layer)_";
|
|
2545
|
+
const row = `| ${label}${visibility} | ${tokenCell(c)} | ${c.hex} |`;
|
|
2372
2546
|
if (emitted.has(row))
|
|
2373
2547
|
return;
|
|
2374
2548
|
emitted.add(row);
|
|
@@ -2388,8 +2562,18 @@ setNode) {
|
|
|
2388
2562
|
for (const c of vd.iconColors)
|
|
2389
2563
|
pushRow(c.iconLabel ? `Icon (${c.iconLabel})` : "Icon", c);
|
|
2390
2564
|
// Text colors
|
|
2391
|
-
|
|
2392
|
-
|
|
2565
|
+
// Same-named text layers with DIFFERENT colors (a selected and an unselected
|
|
2566
|
+
// tab's Label) are told apart by the nested instance they live in
|
|
2567
|
+
const colorsByName = new Map();
|
|
2568
|
+
for (const t of vd.textColors) {
|
|
2569
|
+
if (!colorsByName.has(t.nodeName))
|
|
2570
|
+
colorsByName.set(t.nodeName, new Set());
|
|
2571
|
+
colorsByName.get(t.nodeName).add(`${t.hex}|${t.variableName ?? ""}`);
|
|
2572
|
+
}
|
|
2573
|
+
for (const text of vd.textColors) {
|
|
2574
|
+
const ambiguous = (colorsByName.get(text.nodeName)?.size ?? 0) > 1 && text.owner;
|
|
2575
|
+
pushRow(`Text (${short(text.nodeName)}${ambiguous ? ` in ${text.owner}` : ""})`, text);
|
|
2576
|
+
}
|
|
2393
2577
|
// Strokes — through the same writer, so a visible underline and a hidden
|
|
2394
2578
|
// focus ring sharing one color don't print as two indistinguishable rows
|
|
2395
2579
|
for (const stroke of vd.strokes)
|
|
@@ -2492,7 +2676,7 @@ setNode) {
|
|
|
2492
2676
|
}
|
|
2493
2677
|
return lines.join("\n");
|
|
2494
2678
|
}
|
|
2495
|
-
function generateImplementationSection(codeInfo) {
|
|
2679
|
+
function generateImplementationSection(codeInfo, sourceRoot) {
|
|
2496
2680
|
if (!codeInfo)
|
|
2497
2681
|
return "";
|
|
2498
2682
|
const lines = ["", "## Implementation", ""];
|
|
@@ -2503,7 +2687,7 @@ function generateImplementationSection(codeInfo) {
|
|
|
2503
2687
|
lines.push("| File | Role | Variants |");
|
|
2504
2688
|
lines.push("|------|------|----------|");
|
|
2505
2689
|
for (const sf of codeInfo.sourceFiles) {
|
|
2506
|
-
lines.push(`| \`${sf.path}\` | ${sf.role} | ${sf.variants ?? "—"} |`);
|
|
2690
|
+
lines.push(`| \`${displayPath(sf.path, sourceRoot)}\` | ${sf.role} | ${sf.variants ?? "—"} |`);
|
|
2507
2691
|
}
|
|
2508
2692
|
lines.push("");
|
|
2509
2693
|
}
|
|
@@ -2820,6 +3004,9 @@ export function generateTypographySection(node) {
|
|
|
2820
3004
|
if (seen.has(key))
|
|
2821
3005
|
continue;
|
|
2822
3006
|
seen.add(key);
|
|
3007
|
+
const sameName = textStyles.filter((o) => o.nodeName === ts.nodeName && `${o.fontFamily}:${o.fontWeight}:${o.fontSize}:${o.lineHeight}` !== `${ts.fontFamily}:${ts.fontWeight}:${ts.fontSize}:${ts.lineHeight}`);
|
|
3008
|
+
if (sameName.length > 0 && ts.owner)
|
|
3009
|
+
ts.nodeName = `${ts.nodeName} in ${ts.owner}`;
|
|
2823
3010
|
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`} |`);
|
|
2824
3011
|
}
|
|
2825
3012
|
lines.push("");
|
|
@@ -2846,63 +3033,122 @@ function generateContentGuidelinesSection(parsedDesc) {
|
|
|
2846
3033
|
return lines.join("\n");
|
|
2847
3034
|
}
|
|
2848
3035
|
export function generateParitySection(node, codeInfo) {
|
|
2849
|
-
|
|
2850
|
-
//
|
|
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".)
|
|
3036
|
+
// Names match ignoring case/punctuation and a leading is/has, so Figma's
|
|
3037
|
+
// "Is Selected" meets code's `isSelected` / `selected`.
|
|
2854
3038
|
const normalize = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
3039
|
+
const keysFor = (name) => {
|
|
3040
|
+
const n = normalize(name);
|
|
3041
|
+
const stripped = n.replace(/^(is|has)(?=[a-z0-9])/, "");
|
|
3042
|
+
return stripped && stripped !== n ? [n, stripped] : [n];
|
|
3043
|
+
};
|
|
3044
|
+
const BOOL = /^(true|false)$/i;
|
|
2855
3045
|
const figmaProps = new Map();
|
|
2856
3046
|
if (node.type === "COMPONENT_SET" && node.children) {
|
|
2857
3047
|
for (const child of node.children) {
|
|
2858
3048
|
for (const [propName, value] of parseVariantProperties(child.name || "")) {
|
|
2859
3049
|
const key = normalize(propName);
|
|
2860
3050
|
if (!figmaProps.has(key))
|
|
2861
|
-
figmaProps.set(key, { name: propName, values: new Map() });
|
|
3051
|
+
figmaProps.set(key, { name: propName, kind: "values", values: new Map() });
|
|
2862
3052
|
figmaProps.get(key).values.set(value.toLowerCase(), value);
|
|
2863
3053
|
}
|
|
2864
3054
|
}
|
|
2865
3055
|
}
|
|
2866
|
-
//
|
|
2867
|
-
const
|
|
3056
|
+
// A variant property whose only values are True/False is a boolean in all but name
|
|
3057
|
+
for (const p of figmaProps.values()) {
|
|
3058
|
+
if (p.values.size > 0 && [...p.values.keys()].every((v) => BOOL.test(v)))
|
|
3059
|
+
p.kind = "boolean";
|
|
3060
|
+
}
|
|
3061
|
+
// BOOLEAN component properties (show/hide toggles) are booleans too
|
|
3062
|
+
for (const [rawName, def] of Object.entries(node.componentPropertyDefinitions ?? {})) {
|
|
3063
|
+
if (def?.type !== "BOOLEAN")
|
|
3064
|
+
continue;
|
|
3065
|
+
const name = stripPropertyIdSuffix(rawName);
|
|
3066
|
+
if (!figmaProps.has(normalize(name)))
|
|
3067
|
+
figmaProps.set(normalize(name), { name, kind: "boolean", values: new Map() });
|
|
3068
|
+
}
|
|
3069
|
+
const codeProps = [];
|
|
2868
3070
|
for (const prop of codeInfo.props ?? []) {
|
|
2869
|
-
const
|
|
2870
|
-
|
|
3071
|
+
const type = prop.type ?? "";
|
|
3072
|
+
const literals = type.match(/["']([^"']+)["']/g);
|
|
3073
|
+
const isBoolean = /^\s*boolean\s*(\|\s*(undefined|null)\s*)*$/i.test(type)
|
|
3074
|
+
|| /^\s*(true\s*\|\s*false|false\s*\|\s*true)\s*(\|\s*(undefined|null)\s*)*$/i.test(type);
|
|
3075
|
+
if (isBoolean) {
|
|
3076
|
+
codeProps.push({ name: prop.name, kind: "boolean", values: new Map() });
|
|
3077
|
+
}
|
|
3078
|
+
else if (literals) {
|
|
3079
|
+
const values = new Map();
|
|
3080
|
+
for (const m of literals) {
|
|
3081
|
+
const raw = m.replace(/["']/g, "");
|
|
3082
|
+
values.set(raw.toLowerCase(), raw);
|
|
3083
|
+
}
|
|
3084
|
+
codeProps.push({ name: prop.name, kind: "values", values });
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
// Pair each Figma property with at most one code prop, exact name first
|
|
3088
|
+
const pairs = [];
|
|
3089
|
+
const usedCode = new Set();
|
|
3090
|
+
for (const fp of figmaProps.values()) {
|
|
3091
|
+
const fKeys = keysFor(fp.name);
|
|
3092
|
+
const match = codeProps.find((cp) => !usedCode.has(cp) && normalize(cp.name) === fKeys[0])
|
|
3093
|
+
?? codeProps.find((cp) => !usedCode.has(cp) && keysFor(cp.name).some((k) => fKeys.includes(k)));
|
|
3094
|
+
if (match) {
|
|
3095
|
+
usedCode.add(match);
|
|
3096
|
+
pairs.push({ figma: fp, code: match });
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
const unmatchedFigma = [...figmaProps.values()].filter((fp) => !pairs.some((p) => p.figma === fp));
|
|
3100
|
+
const unmatchedCode = codeProps.filter((cp) => !usedCode.has(cp));
|
|
3101
|
+
// Long-standing behavior: a "variant" prop on one side is compared even with no counterpart
|
|
3102
|
+
for (const fp of unmatchedFigma.filter((p) => normalize(p.name) === "variant" && p.kind === "values"))
|
|
3103
|
+
pairs.push({ figma: fp });
|
|
3104
|
+
for (const cp of unmatchedCode.filter((p) => normalize(p.name) === "variant" && p.kind === "values"))
|
|
3105
|
+
pairs.push({ code: cp });
|
|
3106
|
+
const rows = [];
|
|
3107
|
+
const labelProperty = pairs.length > 1 || (pairs[0] && normalize((pairs[0].figma ?? pairs[0].code).name) !== "variant");
|
|
3108
|
+
for (const { figma, code } of pairs) {
|
|
3109
|
+
const propLabel = figma?.name || code?.name || "";
|
|
3110
|
+
const bothBoolean = figma?.kind === "boolean" && code?.kind === "boolean";
|
|
3111
|
+
if (bothBoolean) {
|
|
3112
|
+
rows.push(`| ${figma.name} ↔ \`${code.name}\` | Yes | Yes | In sync (boolean) |`);
|
|
2871
3113
|
continue;
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
}
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
3114
|
+
}
|
|
3115
|
+
if (figma && code && figma.kind !== code.kind && !(figma.values.size > 0 && code.values.size > 0)) {
|
|
3116
|
+
rows.push(`| ${figma.name} ↔ \`${code.name}\` | Yes | Yes | **Type mismatch** — Figma ${figma.kind === "boolean" ? "True/False" : "named values"}, code \`${codeInfo.props?.find((p) => p.name === code.name)?.type ?? code.kind}\` |`);
|
|
3117
|
+
continue;
|
|
3118
|
+
}
|
|
3119
|
+
const allValues = new Set([...(figma?.values.keys() ?? []), ...(code?.values.keys() ?? [])]);
|
|
3120
|
+
for (const valueKey of allValues) {
|
|
3121
|
+
const inFigma = figma?.values.has(valueKey) ?? false;
|
|
3122
|
+
const inCode = code?.values.has(valueKey) ?? false;
|
|
3123
|
+
const display = figma?.values.get(valueKey) || code?.values.get(valueKey) || valueKey;
|
|
3124
|
+
const status = inFigma && inCode
|
|
3125
|
+
? "In sync"
|
|
3126
|
+
: inFigma ? "Figma-only — needs code variant" : "Code-only — needs Figma variant";
|
|
3127
|
+
rows.push(`| ${labelProperty ? `${propLabel}: ${display}` : display} | ${inFigma ? "Yes" : "**No**"} | ${inCode ? "Yes" : "**No**"} | ${status} |`);
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
const leftoverFigma = unmatchedFigma.filter((p) => !pairs.some((x) => x.figma === p));
|
|
3131
|
+
const leftoverCode = unmatchedCode.filter((p) => !pairs.some((x) => x.code === p));
|
|
3132
|
+
// Nothing on either side that could be compared: leave the section out rather
|
|
3133
|
+
// than print a heading with nothing under it.
|
|
3134
|
+
if (rows.length === 0 && leftoverFigma.length === 0 && leftoverCode.length === 0)
|
|
3135
|
+
return "";
|
|
3136
|
+
const lines = ["", "## Design-Code Parity", ""];
|
|
3137
|
+
if (rows.length > 0) {
|
|
3138
|
+
lines.push("### Variant Coverage", "");
|
|
2889
3139
|
lines.push("| Variant | In Figma | In Code | Status |");
|
|
2890
3140
|
lines.push("|---------|----------|---------|--------|");
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
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} |`);
|
|
2904
|
-
}
|
|
2905
|
-
}
|
|
3141
|
+
lines.push(...rows, "");
|
|
3142
|
+
}
|
|
3143
|
+
else {
|
|
3144
|
+
lines.push("_No Figma property matched a code prop by name and type, so nothing was compared._", "");
|
|
3145
|
+
}
|
|
3146
|
+
if (leftoverFigma.length > 0 || leftoverCode.length > 0) {
|
|
3147
|
+
lines.push("**Not compared** — no counterpart on the other side:", "");
|
|
3148
|
+
if (leftoverFigma.length > 0)
|
|
3149
|
+
lines.push(`- Figma: ${leftoverFigma.map((p) => `${p.name} (${p.kind === "boolean" ? "True/False" : [...p.values.values()].join(" | ")})`).join(", ")}`);
|
|
3150
|
+
if (leftoverCode.length > 0)
|
|
3151
|
+
lines.push(`- Code: ${leftoverCode.map((p) => `\`${p.name}\``).join(", ")}`);
|
|
2906
3152
|
lines.push("");
|
|
2907
3153
|
}
|
|
2908
3154
|
return lines.join("\n");
|
|
@@ -3042,6 +3288,7 @@ const codeDocInfoSchema = z.object({
|
|
|
3042
3288
|
changes: z.string(),
|
|
3043
3289
|
})).optional().describe("Changelog entries"),
|
|
3044
3290
|
filePath: z.string().optional().describe("Component file path"),
|
|
3291
|
+
storybookUrl: z.string().optional().describe("URL of a running Storybook (or the component's story) to link from the doc. Without it, a stories file in sourceFiles is linked as \"Stories source\", not \"Storybook\"."),
|
|
3045
3292
|
packageName: z.string().optional().describe("Package name"),
|
|
3046
3293
|
variantDefinition: z.string().optional().describe("CVA or variant definition code block"),
|
|
3047
3294
|
subComponents: z.array(z.object({
|
|
@@ -3566,6 +3813,30 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
|
|
|
3566
3813
|
// in frontmatter as design provenance — deliberately NOT merged into
|
|
3567
3814
|
// the code-side semver `version` field, which means something else.
|
|
3568
3815
|
const latestDesignVersion = designHistory?.entries[0] ?? null;
|
|
3816
|
+
// Pin the code side to a commit and build stable links (local mode only —
|
|
3817
|
+
// the Worker has no filesystem). Only files git actually TRACKS get links:
|
|
3818
|
+
// code read from an installed package (node_modules) or an unrelated
|
|
3819
|
+
// working directory is shown as a path, never as a guessed URL.
|
|
3820
|
+
let sourceRevision = null;
|
|
3821
|
+
const codePaths = [
|
|
3822
|
+
...(codeInfo?.filePath ? [codeInfo.filePath] : []),
|
|
3823
|
+
...(codeInfo?.sourceFiles?.map((f) => f.path) ?? []),
|
|
3824
|
+
];
|
|
3825
|
+
// Which repo? An explicit history.repoPath, or the repo that CONTAINS an
|
|
3826
|
+
// absolute path. Never the server's own working directory: relative paths
|
|
3827
|
+
// resolved there could match a same-named file in an unrelated project
|
|
3828
|
+
// and pin the page to the wrong history.
|
|
3829
|
+
const firstAbsolute = codePaths.find((p) => isAbsolute(p));
|
|
3830
|
+
const repoPathForLinks = history?.repoPath ?? (firstAbsolute ? dirname(firstAbsolute) : undefined);
|
|
3831
|
+
if (!isRemoteMode && codePaths.length > 0 && repoPathForLinks) {
|
|
3832
|
+
sourceRevision = await resolveSourceRevision({ paths: codePaths, repoPath: repoPathForLinks }).catch(() => null);
|
|
3833
|
+
if (sourceRevision && sourceRevision.tracked.size === 0)
|
|
3834
|
+
sourceRevision = null;
|
|
3835
|
+
}
|
|
3836
|
+
const sourceUrl = (path) => {
|
|
3837
|
+
const rel = sourceRevision?.tracked.get(path);
|
|
3838
|
+
return rel && sourceRevision ? blobUrl(sourceRevision, rel) : null;
|
|
3839
|
+
};
|
|
3569
3840
|
// Resolve sections with defaults
|
|
3570
3841
|
const s = {
|
|
3571
3842
|
overview: true,
|
|
@@ -3587,11 +3858,11 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
|
|
|
3587
3858
|
const includedSections = [];
|
|
3588
3859
|
if (includeFrontmatter) {
|
|
3589
3860
|
const atomicLevel = await detectAtomicLevel(api, fileKey, nodeId, setInfo.setNodeId, componentMeta, allComponentsMeta);
|
|
3590
|
-
parts.push(generateFrontmatter(componentName, description, node, componentMeta, fileUrl_, codeInfo, canonicalSource, atomicLevel, latestDesignVersion));
|
|
3861
|
+
parts.push(generateFrontmatter(componentName, description, node, componentMeta, fileUrl_, codeInfo, canonicalSource, atomicLevel, latestDesignVersion, sourceRevision, repoPathForLinks));
|
|
3591
3862
|
parts.push("");
|
|
3592
3863
|
}
|
|
3593
3864
|
if (s.overview) {
|
|
3594
|
-
parts.push(generateOverviewSection(componentName, description, fileUrl_, parsedDesc, codeInfo));
|
|
3865
|
+
parts.push(generateOverviewSection(componentName, description, fileUrl_, parsedDesc, codeInfo, sourceUrl, repoPathForLinks));
|
|
3595
3866
|
includedSections.push("overview");
|
|
3596
3867
|
}
|
|
3597
3868
|
if (s.anatomy) {
|
|
@@ -3627,7 +3898,7 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
|
|
|
3627
3898
|
}
|
|
3628
3899
|
}
|
|
3629
3900
|
if (s.implementation && codeInfo) {
|
|
3630
|
-
parts.push(generateImplementationSection(codeInfo));
|
|
3901
|
+
parts.push(generateImplementationSection(codeInfo, repoPathForLinks ?? history?.repoPath));
|
|
3631
3902
|
includedSections.push("implementation");
|
|
3632
3903
|
}
|
|
3633
3904
|
if (s.accessibility) {
|
|
@@ -3640,7 +3911,8 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
|
|
|
3640
3911
|
includedSections.push("designAnnotations");
|
|
3641
3912
|
}
|
|
3642
3913
|
if (s.parity && hasCodeInfo && hasFigmaData && codeInfo) {
|
|
3643
|
-
|
|
3914
|
+
// nodeForVariants carries the SET's property definitions when a single variant was requested
|
|
3915
|
+
const paritySection = generateParitySection(nodeForVariants, codeInfo);
|
|
3644
3916
|
if (paritySection) {
|
|
3645
3917
|
parts.push(paritySection);
|
|
3646
3918
|
includedSections.push("parity");
|
|
@@ -3656,6 +3928,8 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
|
|
|
3656
3928
|
componentName,
|
|
3657
3929
|
design: designHistory,
|
|
3658
3930
|
git: gitHistory,
|
|
3931
|
+
commitLink: (sha) => (sourceRevision ? commitUrl(sourceRevision, sha) : null),
|
|
3932
|
+
showPath: (p) => displayPath(p, gitHistory?._meta.repo_root ?? repoPathForLinks),
|
|
3659
3933
|
manual: codeInfo?.changelog ?? null,
|
|
3660
3934
|
});
|
|
3661
3935
|
if (historySection.trim()) {
|