figma-console-mcp 1.40.3 → 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 +3 -2
- package/dist/cloudflare/core/cloud-websocket-connector.js +3 -1
- package/dist/cloudflare/core/design-code-tools.js +143 -90
- 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.map +1 -1
- package/dist/core/design-code-tools.js +143 -90
- 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-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
|
@@ -376,14 +376,16 @@ function describeEffect(effect) {
|
|
|
376
376
|
if (!effect || effect.visible === false || typeof effect.type !== "string")
|
|
377
377
|
return null;
|
|
378
378
|
if (effect.type === "DROP_SHADOW" || effect.type === "INNER_SHADOW") {
|
|
379
|
-
const color = effect.color ? figmaRGBAToHex({ ...effect.color, a: effect.color.a ?? 1 }) : "";
|
|
380
|
-
|
|
381
|
-
|
|
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}` : ""}`;
|
|
382
383
|
}
|
|
384
|
+
const radius = Math.round((effect.radius ?? 0) * 100) / 100;
|
|
383
385
|
if (effect.type === "LAYER_BLUR")
|
|
384
|
-
return `layer blur ${
|
|
386
|
+
return `layer blur ${radius}px`;
|
|
385
387
|
if (effect.type === "BACKGROUND_BLUR")
|
|
386
|
-
return `background blur ${
|
|
388
|
+
return `background blur ${radius}px`;
|
|
387
389
|
return `${effect.type.toLowerCase().replace(/_/g, " ")} effect`;
|
|
388
390
|
}
|
|
389
391
|
/** Largest dimension (px) an instance-swap slot can have and still be treated as an icon */
|
|
@@ -414,13 +416,32 @@ function detectIconInstance(node, lookup) {
|
|
|
414
416
|
if (!nameSaysIcon && !(swapProperty && iconSized))
|
|
415
417
|
return null;
|
|
416
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);
|
|
417
423
|
return {
|
|
418
|
-
name: mainName || cleanedLayerName,
|
|
424
|
+
name: glyph || mainName || cleanedLayerName,
|
|
419
425
|
type: "instance",
|
|
420
426
|
layerName,
|
|
421
427
|
...(swapProperty ? { swapProperty } : {}),
|
|
422
428
|
};
|
|
423
429
|
}
|
|
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
|
+
}
|
|
424
445
|
/** True when the node has at least one visible paint (solid, gradient, image…) */
|
|
425
446
|
function hasVisibleSolidFill(node) {
|
|
426
447
|
return Array.isArray(node?.fills) && node.fills.some((f) => describePaint(f) !== null);
|
|
@@ -626,64 +647,43 @@ const typographySignature = (ts) => `${ts.fontFamily}|${ts.fontWeight}|${ts.font
|
|
|
626
647
|
*/
|
|
627
648
|
export function collectTypographyAcrossVariants(setNode) {
|
|
628
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)}`;
|
|
629
654
|
const perVariant = variants.map((v) => {
|
|
630
|
-
|
|
631
|
-
const
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
seen.set(ts.nodeName, n);
|
|
636
|
-
byElement.set(n === 1 ? ts.nodeName : `${ts.nodeName} #${n}`, ts);
|
|
637
|
-
}
|
|
638
|
-
return { name: cleanVariantName(v.name || "Unknown"), props: parseVariantProperties(v.name || ""), byElement };
|
|
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 };
|
|
639
660
|
});
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
continue;
|
|
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);
|
|
662
682
|
}
|
|
663
|
-
const explanation = explainByVariantProperty(having.map((pv) => ({ props: pv.props, display: typographySignature(pv.byElement.get(el)) })));
|
|
664
|
-
for (const [sig, group] of distinct) {
|
|
665
|
-
const propValue = explanation?.groups.filter(([, display]) => display === sig).map(([pv]) => pv);
|
|
666
|
-
const scope = explanation && propValue && propValue.length > 0
|
|
667
|
-
? `${explanation.property}=${propValue.join(" | ")}`
|
|
668
|
-
: scopeLabel(group.names, perVariant.length);
|
|
669
|
-
rows.push({ style: { ...group.style, nodeName: el }, scope });
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
// Same-named layers ("Label", "Label #2", "Label #3" — three tabs in a tab list)
|
|
673
|
-
// that share a style and a scope are one fact, not three rows.
|
|
674
|
-
const merged = [];
|
|
675
|
-
const seenRows = new Set();
|
|
676
|
-
for (const row of rows) {
|
|
677
|
-
const baseName = row.style.nodeName.replace(/ #\d+$/, "");
|
|
678
|
-
const key = `${baseName}|${typographySignature(row.style)}|${row.scope}|${row.style.hidden ? "h" : ""}`;
|
|
679
|
-
if (seenRows.has(key))
|
|
680
|
-
continue;
|
|
681
|
-
seenRows.add(key);
|
|
682
683
|
// Text layers are often auto-named after their content — keep the table readable
|
|
683
|
-
const label =
|
|
684
|
-
|
|
685
|
-
}
|
|
686
|
-
return merged;
|
|
684
|
+
const label = style.nodeName.length > 40 ? `${style.nodeName.slice(0, 40)}…` : style.nodeName;
|
|
685
|
+
return { style: { ...style, nodeName: label }, scope };
|
|
686
|
+
});
|
|
687
687
|
}
|
|
688
688
|
/** "Primary / Default, Primary / Hover, +3 more" — enough to identify, short enough to read */
|
|
689
689
|
function scopeLabel(names, total) {
|
|
@@ -729,7 +729,9 @@ function countChildDepth(node) {
|
|
|
729
729
|
}
|
|
730
730
|
return 1 + max;
|
|
731
731
|
}
|
|
732
|
-
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 = "") {
|
|
733
735
|
if (depth > maxDepth)
|
|
734
736
|
return;
|
|
735
737
|
const connector = depth === 0 ? "" : (isLast ? "└── " : "├── ");
|
|
@@ -755,12 +757,16 @@ function buildAnatomyLines(node, lines, prefix, isLast, depth, maxDepth) {
|
|
|
755
757
|
let sizingInfo = "";
|
|
756
758
|
if (node.primaryAxisSizingMode || node.counterAxisSizingMode) {
|
|
757
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";
|
|
758
764
|
if (node.primaryAxisSizingMode === "FIXED")
|
|
759
|
-
parts.push("fixed-width");
|
|
765
|
+
parts.push(vertical ? "fixed-height" : "fixed-width");
|
|
760
766
|
if (node.primaryAxisSizingMode === "AUTO")
|
|
761
767
|
parts.push("hug-content");
|
|
762
768
|
if (node.counterAxisSizingMode === "FIXED")
|
|
763
|
-
parts.push("fixed-height");
|
|
769
|
+
parts.push(vertical ? "fixed-width" : "fixed-height");
|
|
764
770
|
if (node.layoutGrow === 1)
|
|
765
771
|
parts.push("fill");
|
|
766
772
|
if (parts.length > 0)
|
|
@@ -769,14 +775,28 @@ function buildAnatomyLines(node, lines, prefix, isLast, depth, maxDepth) {
|
|
|
769
775
|
// Hidden layers stay in the tree, marked: the color table names them (a hidden
|
|
770
776
|
// focus ring is real design intent), so the tree must not pretend they don't exist.
|
|
771
777
|
const hiddenHint = node.visible === false ? " (hidden)" : "";
|
|
772
|
-
lines.push(`${prefix}${connector}${label}${typeHint}${hiddenHint}${layoutInfo}${sizingInfo}`);
|
|
773
|
-
// Recurse into children
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
+
});
|
|
780
800
|
}
|
|
781
801
|
}
|
|
782
802
|
/**
|
|
@@ -2032,15 +2052,15 @@ export function generateOverviewSection(componentName, description, fileUrl, par
|
|
|
2032
2052
|
}
|
|
2033
2053
|
lines.push(links.join(" | "));
|
|
2034
2054
|
lines.push("");
|
|
2035
|
-
|
|
2036
|
-
|
|
2055
|
+
// Collected separately so an empty overview doesn't leave an orphaned heading
|
|
2056
|
+
const section = [];
|
|
2037
2057
|
// Use parsed overview or fall back to raw description
|
|
2038
2058
|
// No filler: libraries that deliberately keep descriptions empty would get a
|
|
2039
2059
|
// sentence carrying no information on every page. Nothing says more than that.
|
|
2040
2060
|
const overviewText = parsedDesc.overview || description?.split("\n")[0] || "";
|
|
2041
2061
|
if (overviewText.trim()) {
|
|
2042
|
-
|
|
2043
|
-
|
|
2062
|
+
section.push(overviewText);
|
|
2063
|
+
section.push("");
|
|
2044
2064
|
}
|
|
2045
2065
|
// Base component attribution
|
|
2046
2066
|
if (codeInfo?.baseComponent) {
|
|
@@ -2048,30 +2068,35 @@ export function generateOverviewSection(componentName, description, fileUrl, par
|
|
|
2048
2068
|
? `[${codeInfo.baseComponent.name}](${codeInfo.baseComponent.url})`
|
|
2049
2069
|
: codeInfo.baseComponent.name;
|
|
2050
2070
|
if (codeInfo.baseComponent.description) {
|
|
2051
|
-
|
|
2071
|
+
section.push(`Built on ${baseLink}, ${codeInfo.baseComponent.description}`);
|
|
2052
2072
|
}
|
|
2053
2073
|
else {
|
|
2054
|
-
|
|
2074
|
+
section.push(`Built on ${baseLink}.`);
|
|
2055
2075
|
}
|
|
2056
|
-
|
|
2076
|
+
section.push("");
|
|
2057
2077
|
}
|
|
2058
2078
|
// When to Use
|
|
2059
2079
|
if (parsedDesc.whenToUse.length > 0) {
|
|
2060
|
-
|
|
2061
|
-
|
|
2080
|
+
section.push("### When to Use");
|
|
2081
|
+
section.push("");
|
|
2062
2082
|
for (const item of parsedDesc.whenToUse) {
|
|
2063
|
-
|
|
2083
|
+
section.push(`- ${item}`);
|
|
2064
2084
|
}
|
|
2065
|
-
|
|
2085
|
+
section.push("");
|
|
2066
2086
|
}
|
|
2067
2087
|
// When NOT to Use
|
|
2068
2088
|
if (parsedDesc.whenNotToUse.length > 0) {
|
|
2069
|
-
|
|
2070
|
-
|
|
2089
|
+
section.push("### When NOT to Use");
|
|
2090
|
+
section.push("");
|
|
2071
2091
|
for (const item of parsedDesc.whenNotToUse) {
|
|
2072
|
-
|
|
2092
|
+
section.push(`- ${item}`);
|
|
2073
2093
|
}
|
|
2094
|
+
section.push("");
|
|
2095
|
+
}
|
|
2096
|
+
if (section.length > 0) {
|
|
2097
|
+
lines.push("## Overview");
|
|
2074
2098
|
lines.push("");
|
|
2099
|
+
lines.push(...section);
|
|
2075
2100
|
}
|
|
2076
2101
|
return lines.join("\n");
|
|
2077
2102
|
}
|
|
@@ -2079,8 +2104,30 @@ export function generateOverviewSection(componentName, description, fileUrl, par
|
|
|
2079
2104
|
function formatIconCell(icons) {
|
|
2080
2105
|
if (!icons || icons.length === 0)
|
|
2081
2106
|
return "—";
|
|
2082
|
-
// A chip with a leading AND a trailing icon has two — show both
|
|
2083
|
-
|
|
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("; ");
|
|
2084
2131
|
}
|
|
2085
2132
|
function formatOneIcon(icon) {
|
|
2086
2133
|
const notes = [];
|
|
@@ -2318,6 +2365,8 @@ setNode) {
|
|
|
2318
2365
|
};
|
|
2319
2366
|
// Multi-path icons and repeated shapes would otherwise emit identical rows
|
|
2320
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);
|
|
2321
2370
|
const pushRow = (label, c) => {
|
|
2322
2371
|
const row = `| ${label}${c.hidden ? " _(hidden layer)_" : ""} | ${tokenCell(c)} | ${c.hex} |`;
|
|
2323
2372
|
if (emitted.has(row))
|
|
@@ -2340,7 +2389,7 @@ setNode) {
|
|
|
2340
2389
|
pushRow(c.iconLabel ? `Icon (${c.iconLabel})` : "Icon", c);
|
|
2341
2390
|
// Text colors
|
|
2342
2391
|
for (const text of vd.textColors)
|
|
2343
|
-
pushRow(`Text (${text.nodeName})`, text);
|
|
2392
|
+
pushRow(`Text (${short(text.nodeName)})`, text);
|
|
2344
2393
|
// Strokes — through the same writer, so a visible underline and a hidden
|
|
2345
2394
|
// focus ring sharing one color don't print as two indistinguishable rows
|
|
2346
2395
|
for (const stroke of vd.strokes)
|
|
@@ -3405,7 +3454,11 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
|
|
|
3405
3454
|
if (getDesktopConnector) {
|
|
3406
3455
|
try {
|
|
3407
3456
|
const connector = await getDesktopConnector();
|
|
3408
|
-
|
|
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);
|
|
3409
3462
|
const varList = varsResult?.variables || varsResult?.result?.variables;
|
|
3410
3463
|
if (Array.isArray(varList)) {
|
|
3411
3464
|
for (const [id, name] of buildVariableNameMap(varList, varsResult?.variableCollections || varsResult?.result?.variableCollections))
|
|
@@ -3434,7 +3487,7 @@ export function registerDesignCodeTools(server, getFigmaAPI, getCurrentUrl, vari
|
|
|
3434
3487
|
if (getDesktopConnector) {
|
|
3435
3488
|
try {
|
|
3436
3489
|
const connector = await getDesktopConnector();
|
|
3437
|
-
const bridgeResult = await connector.getComponentFromPluginUI(nodeId);
|
|
3490
|
+
const bridgeResult = await connector.getComponentFromPluginUI(nodeId, fileKey);
|
|
3438
3491
|
if (bridgeResult.success && bridgeResult.component) {
|
|
3439
3492
|
// Fetch description from bridge if REST API returned empty
|
|
3440
3493
|
if (!description) {
|