@mp3wizard/figma-console-mcp 1.22.6 β 1.23.1
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 +15 -38
- package/dist/cloudflare/core/design-code-tools.js +3 -29
- package/dist/cloudflare/core/diff/changelog-formatter.js +275 -0
- package/dist/cloudflare/core/diff/diff-engine.js +334 -0
- package/dist/cloudflare/core/diff/property-compare.js +36 -0
- package/dist/cloudflare/core/diff/version-cache.js +74 -0
- package/dist/cloudflare/core/figma-api.js +19 -0
- package/dist/cloudflare/core/version-tools.js +1014 -0
- package/dist/cloudflare/index.js +17 -13
- package/dist/core/design-code-tools.d.ts +1 -12
- package/dist/core/design-code-tools.d.ts.map +1 -1
- package/dist/core/design-code-tools.js +3 -29
- package/dist/core/design-code-tools.js.map +1 -1
- package/dist/core/diff/changelog-formatter.d.ts +35 -0
- package/dist/core/diff/changelog-formatter.d.ts.map +1 -0
- package/dist/core/diff/changelog-formatter.js +276 -0
- package/dist/core/diff/changelog-formatter.js.map +1 -0
- package/dist/core/diff/diff-engine.d.ts +113 -0
- package/dist/core/diff/diff-engine.d.ts.map +1 -0
- package/dist/core/diff/diff-engine.js +335 -0
- package/dist/core/diff/diff-engine.js.map +1 -0
- package/dist/core/diff/property-compare.d.ts +19 -0
- package/dist/core/diff/property-compare.d.ts.map +1 -0
- package/dist/core/diff/property-compare.js +37 -0
- package/dist/core/diff/property-compare.js.map +1 -0
- package/dist/core/diff/version-cache.d.ts +40 -0
- package/dist/core/diff/version-cache.d.ts.map +1 -0
- package/dist/core/diff/version-cache.js +75 -0
- package/dist/core/diff/version-cache.js.map +1 -0
- package/dist/core/figma-api.d.ts +29 -0
- package/dist/core/figma-api.d.ts.map +1 -1
- package/dist/core/figma-api.js +19 -0
- package/dist/core/figma-api.js.map +1 -1
- package/dist/core/version-tools.d.ts +30 -0
- package/dist/core/version-tools.d.ts.map +1 -0
- package/dist/core/version-tools.js +1015 -0
- package/dist/core/version-tools.js.map +1 -0
- package/dist/local.d.ts.map +1 -1
- package/dist/local.js +8 -0
- package/dist/local.js.map +1 -1
- package/figma-desktop-bridge/code.js +1 -1
- package/package.json +108 -1
package/README.md
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
# Figma Console MCP Server
|
|
2
2
|
|
|
3
3
|
[](https://modelcontextprotocol.io/)
|
|
4
|
-
[](https://www.npmjs.com/package/figma-console-mcp)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
|
-
[](Security%20review%20report/)
|
|
7
6
|
[](https://docs.figma-console-mcp.southleft.com)
|
|
8
7
|
[](https://github.com/sponsors/southleft)
|
|
9
8
|
|
|
10
9
|
> **Your design system as an API.** Model Context Protocol server that bridges design and developmentβgiving AI assistants complete access to Figma for **extraction**, **creation**, and **debugging**.
|
|
11
10
|
|
|
12
|
-
>
|
|
13
|
-
|
|
14
|
-
> **π High-Fidelity Design-to-Code:** Deep component trees (depth 4), resolved design tokens, interaction state machines with CSS mappings, and codebase-aware component scanning. AI gets everything a senior engineer needs β tokens, sizing, states, annotations, and a cross-reference of what already exists in your codebase. [See what's new β](docs/figma-mcp-vs-figma-console-mcp.md)
|
|
11
|
+
> **π Version History & Time-Series Awareness (v1.23.0):** Six new tools turn a Figma file from a static snapshot into a queryable history β list versions, snapshot any past version, diff two versions for component/binding deltas, generate markdown changelogs ready for release notes, and trace exactly when (and by whom) a property or variant was introduced via a binary-search blame walker. Author attribution flows from autosaves, not just labeled releases. [See what's new β](CHANGELOG.md#1230---2026-05-09)
|
|
15
12
|
|
|
16
13
|
## What is this?
|
|
17
14
|
|
|
@@ -54,9 +51,9 @@ Figma Console MCP connects AI assistants (like Claude) to Figma, enabling:
|
|
|
54
51
|
| Real-time monitoring (console, selection) | β
| β | β |
|
|
55
52
|
| Desktop Bridge plugin | β
| β
| β |
|
|
56
53
|
| Requires Node.js | Yes | **No** | No |
|
|
57
|
-
| **Total tools available** | **
|
|
54
|
+
| **Total tools available** | **100+** | **83** | **9** |
|
|
58
55
|
|
|
59
|
-
> **Bottom line:** Remote SSE is **read-only** with ~38% of the tools. **Cloud Mode** unlocks write access from web AI clients without Node.js. NPX/Local Git gives the full
|
|
56
|
+
> **Bottom line:** Remote SSE is **read-only** with ~38% of the tools. **Cloud Mode** unlocks write access from web AI clients without Node.js. NPX/Local Git gives the full 100+ tools with real-time monitoring.
|
|
60
57
|
|
|
61
58
|
---
|
|
62
59
|
|
|
@@ -64,7 +61,7 @@ Figma Console MCP connects AI assistants (like Claude) to Figma, enabling:
|
|
|
64
61
|
|
|
65
62
|
**Best for:** Designers who want full AI-assisted design capabilities.
|
|
66
63
|
|
|
67
|
-
**What you get:** All
|
|
64
|
+
**What you get:** All 100+ tools including design creation, variable management, and component instantiation.
|
|
68
65
|
|
|
69
66
|
#### Prerequisites
|
|
70
67
|
|
|
@@ -77,14 +74,14 @@ Figma Console MCP connects AI assistants (like Claude) to Figma, enabling:
|
|
|
77
74
|
1. Go to [Manage personal access tokens](https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens) in Figma Help
|
|
78
75
|
2. Follow the steps to **create a new personal access token**
|
|
79
76
|
3. Enter description: `Figma Console MCP`
|
|
80
|
-
4. Set scopes: **File content** (Read), **Variables** (Read), **Comments** (Read and write)
|
|
77
|
+
4. Set scopes: **File content** (Read), **File versions** (Read), **Variables** (Read), **Comments** (Read and write)
|
|
81
78
|
5. **Copy the token** β you won't see it again! (starts with `figd_`)
|
|
82
79
|
|
|
83
80
|
#### Step 2: Configure Your MCP Client
|
|
84
81
|
|
|
85
82
|
**Claude Code (CLI):**
|
|
86
83
|
```bash
|
|
87
|
-
claude mcp add figma-console -s user -e FIGMA_ACCESS_TOKEN=figd_YOUR_TOKEN_HERE -e ENABLE_MCP_APPS=true -- npx -y
|
|
84
|
+
claude mcp add figma-console -s user -e FIGMA_ACCESS_TOKEN=figd_YOUR_TOKEN_HERE -e ENABLE_MCP_APPS=true -- npx -y figma-console-mcp@latest
|
|
88
85
|
```
|
|
89
86
|
|
|
90
87
|
**Cursor / Windsurf / Claude Desktop:**
|
|
@@ -96,7 +93,7 @@ Add to your MCP config file (see [Where to find your config file](#-where-to-fin
|
|
|
96
93
|
"mcpServers": {
|
|
97
94
|
"figma-console": {
|
|
98
95
|
"command": "npx",
|
|
99
|
-
"args": ["-y", "
|
|
96
|
+
"args": ["-y", "figma-console-mcp@latest"],
|
|
100
97
|
"env": {
|
|
101
98
|
"FIGMA_ACCESS_TOKEN": "figd_YOUR_TOKEN_HERE",
|
|
102
99
|
"ENABLE_MCP_APPS": "true"
|
|
@@ -133,7 +130,7 @@ If you're not sure where to put the JSON configuration above, here's where each
|
|
|
133
130
|
|
|
134
131
|
> One-time setup. The plugin uses a bootloader that dynamically loads fresh code from the MCP server β no need to re-import when the server updates.
|
|
135
132
|
|
|
136
|
-
> **Upgrading from v1.14 or earlier?** Your existing plugin still works, but to get the bootloader benefits (no more re-importing), do one final re-import from `~/.figma-console-mcp/plugin/manifest.json`. The path is created automatically when the MCP server starts. Run `npx
|
|
133
|
+
> **Upgrading from v1.14 or earlier?** Your existing plugin still works, but to get the bootloader benefits (no more re-importing), do one final re-import from `~/.figma-console-mcp/plugin/manifest.json`. The path is created automatically when the MCP server starts. Run `npx figma-console-mcp@latest --print-path` to see it. After this one-time upgrade, you're done forever.
|
|
137
134
|
|
|
138
135
|
#### Step 4: Restart Your MCP Client
|
|
139
136
|
|
|
@@ -159,7 +156,7 @@ Create a simple frame with a blue background
|
|
|
159
156
|
|
|
160
157
|
**Best for:** Developers who want to modify source code or contribute to the project.
|
|
161
158
|
|
|
162
|
-
**What you get:** Same
|
|
159
|
+
**What you get:** Same 100+ tools as NPX, plus full source code access.
|
|
163
160
|
|
|
164
161
|
#### Quick Setup
|
|
165
162
|
|
|
@@ -305,7 +302,7 @@ AI Client β Cloud MCP Server β Durable Object Relay β Desktop Bridge Plugi
|
|
|
305
302
|
| Feature | NPX (Recommended) | Cloud Mode | Local Git | Remote SSE |
|
|
306
303
|
|---------|-------------------|------------|-----------|------------|
|
|
307
304
|
| **Setup time** | ~10 minutes | ~5 minutes | ~15 minutes | ~2 minutes |
|
|
308
|
-
| **Total tools** | **
|
|
305
|
+
| **Total tools** | **100+** | **83** | **100+** | **9** (read-only) |
|
|
309
306
|
| **Design creation** | β
| β
| β
| β |
|
|
310
307
|
| **Variable management** | β
| β
| β
| β |
|
|
311
308
|
| **Component instantiation** | β
| β
| β
| β |
|
|
@@ -320,7 +317,7 @@ AI Client β Cloud MCP Server β Durable Object Relay β Desktop Bridge Plugi
|
|
|
320
317
|
| **Automatic updates** | β
(`@latest`) | β
| Manual (`git pull`) | β
|
|
|
321
318
|
| **Source code access** | β | β | β
| β |
|
|
322
319
|
|
|
323
|
-
> **Key insight:** Remote SSE is read-only. Cloud Mode adds write access for web AI clients without Node.js. NPX/Local Git give the full
|
|
320
|
+
> **Key insight:** Remote SSE is read-only. Cloud Mode adds write access for web AI clients without Node.js. NPX/Local Git give the full 100+ tools.
|
|
324
321
|
|
|
325
322
|
**π [Complete Feature Comparison](docs/mode-comparison.md)**
|
|
326
323
|
|
|
@@ -366,7 +363,7 @@ When you first use design system tools:
|
|
|
366
363
|
### Local Mode - Personal Access Token (Manual)
|
|
367
364
|
|
|
368
365
|
1. Visit https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens
|
|
369
|
-
2. Generate token with scopes: **File content** (Read), **Variables** (Read), **Comments** (Read and write)
|
|
366
|
+
2. Generate token with scopes: **File content** (Read), **File versions** (Read), **Variables** (Read), **Comments** (Read and write)
|
|
370
367
|
3. Add to MCP config as `FIGMA_ACCESS_TOKEN` environment variable
|
|
371
368
|
|
|
372
369
|
---
|
|
@@ -654,7 +651,7 @@ The **Figma Desktop Bridge** plugin is the recommended way to connect Figma to t
|
|
|
654
651
|
- The MCP server communicates via **WebSocket** through the Desktop Bridge plugin
|
|
655
652
|
- The server tries port 9223 first, then automatically falls back through ports 9224β9232 if needed
|
|
656
653
|
- The plugin scans all ports in the range and connects to every active server it finds
|
|
657
|
-
- All
|
|
654
|
+
- All 100+ tools work through the WebSocket transport
|
|
658
655
|
|
|
659
656
|
**Multiple files:** The WebSocket server supports multiple simultaneous plugin connections β one per open Figma file. Each connection is tracked by file key with independent state (selection, document changes, console logs).
|
|
660
657
|
|
|
@@ -791,11 +788,9 @@ The architecture supports adding new apps with minimal boilerplate β each app
|
|
|
791
788
|
|
|
792
789
|
## π€οΈ Roadmap
|
|
793
790
|
|
|
794
|
-
**Current Status:** v1.
|
|
791
|
+
**Current Status:** v1.17.0 (Stable) - Production-ready with FigJam + Slides support, Cloud Write Relay, Design System Kit, WebSocket-only connectivity, smart multi-file tracking, 100+ tools, Comments API, and MCP Apps
|
|
795
792
|
|
|
796
793
|
**Recent Releases:**
|
|
797
|
-
- [x] **v1.22.4** - Security: fix 6 Medium hono/node-server CVEs via package.json overrides (CVE-2026-39406 through CVE-2026-39410, GHSA-26pp-8wgv-hjvm, GHSA-xpcf-pg52-r92g)
|
|
798
|
-
- [x] **v1.22.3** - Phase B accessibility: disabled variant context check, token misuse detection, WCAG interpretation fixes from accessibility consultant review, rule count 13 to 14
|
|
799
794
|
- [x] **v1.17.0** - Figma Slides Support: 15 new tools for managing presentations β slides, transitions, content, reordering, and navigation. Inspired by Toni Haidamous (PR #11).
|
|
800
795
|
- [x] **v1.16.0** - FigJam Support: 9 new tools for creating and reading FigJam boards β stickies, flowcharts, tables, code blocks, and connection graphs. Community-contributed by klgral and lukemoderwell.
|
|
801
796
|
- [x] **v1.12.0** - Cloud Write Relay: web AI clients (Claude.ai, v0, Replit, Lovable) can create and modify Figma designs via cloud relay pairing β no Node.js required
|
|
@@ -840,24 +835,6 @@ npm run build
|
|
|
840
835
|
|
|
841
836
|
---
|
|
842
837
|
|
|
843
|
-
## π Network Transparency
|
|
844
|
-
|
|
845
|
-
All outbound network connections made by this MCP server:
|
|
846
|
-
|
|
847
|
-
| Destination | Protocol | Purpose | Data Sent |
|
|
848
|
-
|-------------|----------|---------|-----------|
|
|
849
|
-
| `api.figma.com` | HTTPS | REST API (files, variables, components, styles, images, comments) | File keys, node IDs, API parameters |
|
|
850
|
-
| `www.figma.com` | HTTPS | OAuth 2.0 authorization flow | Client ID, auth codes, refresh tokens |
|
|
851
|
-
| `figma-console-mcp.southleft.com` | WSS/HTTPS | Cloud relay for web AI clients (Cloud Mode only) | Metadata only: fileName, fileKey, currentPage |
|
|
852
|
-
| Figma S3 CDN | HTTPS | Rendered image downloads (temporary URLs) | None (download only) |
|
|
853
|
-
| `localhost:9223-9232` | WS | Desktop Bridge plugin (local only) | Plugin commands/responses |
|
|
854
|
-
|
|
855
|
-
**Not present:** telemetry, analytics, tracking, third-party data services, obfuscated code, or environment variable leakage. Full audit available in [`Security review report/`](Security%20review%20report/).
|
|
856
|
-
|
|
857
|
-
> **Local Mode users:** `src/local.ts` does not connect to the cloud relay β only Figma API and localhost WebSocket.
|
|
858
|
-
|
|
859
|
-
---
|
|
860
|
-
|
|
861
838
|
## π License
|
|
862
839
|
|
|
863
840
|
MIT - See [LICENSE](LICENSE) file for details.
|
|
@@ -12,35 +12,9 @@ const enrichmentService = new EnrichmentService(logger);
|
|
|
12
12
|
// ============================================================================
|
|
13
13
|
// Shared Helpers
|
|
14
14
|
// ============================================================================
|
|
15
|
-
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
const g = Math.round(color.g * 255);
|
|
19
|
-
const b = Math.round(color.b * 255);
|
|
20
|
-
const hex = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase();
|
|
21
|
-
if (color.a !== undefined && color.a < 1) {
|
|
22
|
-
const a = Math.round(color.a * 255);
|
|
23
|
-
return `${hex}${a.toString(16).padStart(2, "0")}`;
|
|
24
|
-
}
|
|
25
|
-
return hex;
|
|
26
|
-
}
|
|
27
|
-
/** Normalize a color string for comparison (uppercase hex without alpha if fully opaque) */
|
|
28
|
-
export function normalizeColor(color) {
|
|
29
|
-
let c = color.trim().toUpperCase();
|
|
30
|
-
// Strip alpha if fully opaque (FF)
|
|
31
|
-
if (c.length === 9 && c.endsWith("FF")) {
|
|
32
|
-
c = c.slice(0, 7);
|
|
33
|
-
}
|
|
34
|
-
// Expand shorthand (#RGB -> #RRGGBB)
|
|
35
|
-
if (/^#[0-9A-F]{3}$/.test(c)) {
|
|
36
|
-
c = `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}`;
|
|
37
|
-
}
|
|
38
|
-
return c;
|
|
39
|
-
}
|
|
40
|
-
/** Compare numeric values with a tolerance */
|
|
41
|
-
export function numericClose(a, b, tolerance = 1) {
|
|
42
|
-
return Math.abs(a - b) <= tolerance;
|
|
43
|
-
}
|
|
15
|
+
// Re-exported from the shared diff module so existing imports continue to work.
|
|
16
|
+
export { figmaRGBAToHex, normalizeColor, numericClose } from "./diff/property-compare.js";
|
|
17
|
+
import { figmaRGBAToHex, normalizeColor, numericClose } from "./diff/property-compare.js";
|
|
44
18
|
/** Calculate parity score from discrepancy counts */
|
|
45
19
|
export function calculateParityScore(critical, major, minor, info) {
|
|
46
20
|
return Math.max(0, 100 - (critical * 15 + major * 8 + minor * 3 + info * 1));
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure-function markdown formatter for version diff results.
|
|
3
|
+
*
|
|
4
|
+
* Takes a diff payload (the structured response from figma_diff_versions)
|
|
5
|
+
* plus optional author/label metadata for the from/to versions, and produces
|
|
6
|
+
* a release-notes-style markdown string.
|
|
7
|
+
*
|
|
8
|
+
* Mode-aware:
|
|
9
|
+
* summary β header + a single line of counts
|
|
10
|
+
* standard β header + page section + per-component counts
|
|
11
|
+
* detailed β header + page section + per-component property/binding details
|
|
12
|
+
*
|
|
13
|
+
* Pure: no I/O, no side effects. Trivially testable.
|
|
14
|
+
*/
|
|
15
|
+
export function formatChangelogMarkdown(input, mode = "standard") {
|
|
16
|
+
const lines = [];
|
|
17
|
+
// Header
|
|
18
|
+
const title = input.file_name ? `${input.file_name} β Change Log` : "Figma File Change Log";
|
|
19
|
+
lines.push(`# ${title}`);
|
|
20
|
+
lines.push("");
|
|
21
|
+
lines.push(`**From:** ${formatVersionRef(input.from_meta, input.from_version_id, false)}`);
|
|
22
|
+
lines.push(`**To:** ${formatVersionRef(input.to_meta, input.to_version_id, true)}`);
|
|
23
|
+
const span = computeSpanDays(input.from_meta?.created_at, input.to_meta?.created_at);
|
|
24
|
+
if (span !== null) {
|
|
25
|
+
lines.push(`**Span:** ${span} day${span === 1 ? "" : "s"}`);
|
|
26
|
+
}
|
|
27
|
+
lines.push("");
|
|
28
|
+
// Summary mode: one-line punch line
|
|
29
|
+
if (mode === "summary") {
|
|
30
|
+
lines.push(formatSummaryLine(input));
|
|
31
|
+
lines.push("");
|
|
32
|
+
appendNotes(lines, input.notes);
|
|
33
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
34
|
+
}
|
|
35
|
+
// Page Structure section (always for standard/detailed)
|
|
36
|
+
appendPageStructureSection(lines, input.page_structure);
|
|
37
|
+
// Components section
|
|
38
|
+
if (input.scoped_nodes && input.scoped_nodes.length > 0) {
|
|
39
|
+
appendComponentsSection(lines, input.scoped_nodes, mode);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
lines.push("## Components");
|
|
43
|
+
lines.push("");
|
|
44
|
+
lines.push("_No components were scoped for this changelog. Pass `component_ids` to include per-component changes._");
|
|
45
|
+
lines.push("");
|
|
46
|
+
}
|
|
47
|
+
appendNotes(lines, input.notes);
|
|
48
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
49
|
+
}
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// Section formatters
|
|
52
|
+
// ============================================================================
|
|
53
|
+
function appendPageStructureSection(lines, page) {
|
|
54
|
+
const total = page.summary.added + page.summary.removed + page.summary.renamed;
|
|
55
|
+
lines.push("## Page Structure");
|
|
56
|
+
lines.push("");
|
|
57
|
+
if (total === 0) {
|
|
58
|
+
lines.push("_No page-level changes._");
|
|
59
|
+
lines.push("");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (page.pages_added.length > 0) {
|
|
63
|
+
lines.push(`**Added (${page.pages_added.length}):**`);
|
|
64
|
+
for (const p of page.pages_added)
|
|
65
|
+
lines.push(`- ${escapeMd(p.name)} \`${p.id}\``);
|
|
66
|
+
lines.push("");
|
|
67
|
+
}
|
|
68
|
+
if (page.pages_removed.length > 0) {
|
|
69
|
+
lines.push(`**Removed (${page.pages_removed.length}):**`);
|
|
70
|
+
for (const p of page.pages_removed)
|
|
71
|
+
lines.push(`- ${escapeMd(p.name)} \`${p.id}\``);
|
|
72
|
+
lines.push("");
|
|
73
|
+
}
|
|
74
|
+
if (page.pages_renamed.length > 0) {
|
|
75
|
+
lines.push(`**Renamed (${page.pages_renamed.length}):**`);
|
|
76
|
+
for (const r of page.pages_renamed) {
|
|
77
|
+
lines.push(`- \`${r.id}\`: ${escapeMd(r.old_name)} β ${escapeMd(r.new_name)}`);
|
|
78
|
+
}
|
|
79
|
+
lines.push("");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function appendComponentsSection(lines, scoped, mode) {
|
|
83
|
+
lines.push("## Components");
|
|
84
|
+
lines.push("");
|
|
85
|
+
const withChanges = scoped.filter((n) => n.change_count > 0);
|
|
86
|
+
const unchanged = scoped.filter((n) => n.change_count === 0 && n.notes.length === 0);
|
|
87
|
+
const notFound = scoped.filter((n) => n.notes.some((note) => note.toLowerCase().includes("not found")));
|
|
88
|
+
if (withChanges.length === 0 && unchanged.length === 0 && notFound.length === 0) {
|
|
89
|
+
lines.push("_No scoped components had changes._");
|
|
90
|
+
lines.push("");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
for (const n of withChanges) {
|
|
94
|
+
appendComponentBlock(lines, n, mode);
|
|
95
|
+
}
|
|
96
|
+
if (unchanged.length > 0) {
|
|
97
|
+
lines.push(`**No changes:** ${unchanged.map((n) => `\`${n.node_name || n.node_id}\``).join(", ")}`);
|
|
98
|
+
lines.push("");
|
|
99
|
+
}
|
|
100
|
+
if (notFound.length > 0) {
|
|
101
|
+
lines.push(`**Not found in either version:** ${notFound.map((n) => `\`${n.node_id || "(empty)"}\``).join(", ")}`);
|
|
102
|
+
lines.push("");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function appendComponentBlock(lines, n, mode) {
|
|
106
|
+
const heading = n.node_name ? `${n.node_name}` : "(unnamed)";
|
|
107
|
+
lines.push(`### ${escapeMd(heading)} β \`${n.node_id}\``);
|
|
108
|
+
lines.push(`**${n.change_count} change${n.change_count === 1 ? "" : "s"}**`);
|
|
109
|
+
// Each subsequent sub-section adds its own single-blank-line separator so
|
|
110
|
+
// we don't end up with two consecutive blank lines when one of these
|
|
111
|
+
// sub-sections is empty. (Earlier versions unconditionally pushed a blank
|
|
112
|
+
// after the change count then again before each sub-section, producing
|
|
113
|
+
// double-blanks in the common case.)
|
|
114
|
+
const bullets = [];
|
|
115
|
+
if (n.name_changed) {
|
|
116
|
+
bullets.push(`- Renamed: \`${escapeMd(n.name_changed.from)}\` β \`${escapeMd(n.name_changed.to)}\``);
|
|
117
|
+
}
|
|
118
|
+
if (n.description_changed) {
|
|
119
|
+
const fromLen = n.description_changed.from.length;
|
|
120
|
+
const toLen = n.description_changed.to.length;
|
|
121
|
+
bullets.push(`- Description changed (${fromLen} β ${toLen} chars)`);
|
|
122
|
+
}
|
|
123
|
+
if (n.children_added.length > 0) {
|
|
124
|
+
const inline = n.children_added.map((c) => `\`${escapeMd(c.name || c.id)}\``).join(", ");
|
|
125
|
+
bullets.push(`- Children added (${n.children_added.length}): ${inline}`);
|
|
126
|
+
}
|
|
127
|
+
if (n.children_removed.length > 0) {
|
|
128
|
+
const inline = n.children_removed.map((c) => `\`${escapeMd(c.name || c.id)}\``).join(", ");
|
|
129
|
+
bullets.push(`- Children removed (${n.children_removed.length}): ${inline}`);
|
|
130
|
+
}
|
|
131
|
+
if (bullets.length > 0) {
|
|
132
|
+
lines.push("");
|
|
133
|
+
lines.push(...bullets);
|
|
134
|
+
}
|
|
135
|
+
if (n.component_properties && hasAnyPropChanges(n.component_properties.summary)) {
|
|
136
|
+
const s = n.component_properties.summary;
|
|
137
|
+
const parts = [];
|
|
138
|
+
if (s.added > 0)
|
|
139
|
+
parts.push(`${s.added} added`);
|
|
140
|
+
if (s.removed > 0)
|
|
141
|
+
parts.push(`${s.removed} removed`);
|
|
142
|
+
if (s.type_changed > 0)
|
|
143
|
+
parts.push(`${s.type_changed} type changed`);
|
|
144
|
+
if (s.default_changed > 0)
|
|
145
|
+
parts.push(`${s.default_changed} default changed`);
|
|
146
|
+
lines.push("");
|
|
147
|
+
lines.push(`**Component properties:** ${parts.join(", ")}`);
|
|
148
|
+
if (mode === "detailed") {
|
|
149
|
+
for (const p of n.component_properties.added) {
|
|
150
|
+
lines.push(`- β \`${escapeMd(p.name)}\` (${p.type}, default: \`${stringifyValue(p.default_value)}\`)`);
|
|
151
|
+
}
|
|
152
|
+
for (const p of n.component_properties.removed) {
|
|
153
|
+
lines.push(`- β \`${escapeMd(p.name)}\` (${p.type})`);
|
|
154
|
+
}
|
|
155
|
+
for (const t of n.component_properties.type_changed) {
|
|
156
|
+
lines.push(`- π \`${escapeMd(t.name)}\`: ${t.from_type} β ${t.to_type}`);
|
|
157
|
+
}
|
|
158
|
+
for (const d of n.component_properties.default_changed) {
|
|
159
|
+
lines.push(`- βοΈ \`${escapeMd(d.name)}\`: \`${stringifyValue(d.from_default)}\` β \`${stringifyValue(d.to_default)}\``);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (n.binding_changes.length > 0) {
|
|
164
|
+
lines.push("");
|
|
165
|
+
lines.push(`**Variable bindings (${n.binding_changes.length}):**`);
|
|
166
|
+
if (mode === "detailed") {
|
|
167
|
+
for (const b of n.binding_changes) {
|
|
168
|
+
const arrow = b.change_kind === "added"
|
|
169
|
+
? `β \`${b.to_variable_id}\``
|
|
170
|
+
: b.change_kind === "removed"
|
|
171
|
+
? `(removed, was \`${b.from_variable_id}\`)`
|
|
172
|
+
: `\`${b.from_variable_id}\` β \`${b.to_variable_id}\``;
|
|
173
|
+
lines.push(`- ${escapeMd(b.node_name || b.node_id)} β \`${b.property}\` ${arrow}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
// standard mode: group by change_kind, give counts
|
|
178
|
+
const counts = { added: 0, removed: 0, rebound: 0 };
|
|
179
|
+
for (const b of n.binding_changes)
|
|
180
|
+
counts[b.change_kind]++;
|
|
181
|
+
const parts = [];
|
|
182
|
+
if (counts.added > 0)
|
|
183
|
+
parts.push(`${counts.added} added`);
|
|
184
|
+
if (counts.removed > 0)
|
|
185
|
+
parts.push(`${counts.removed} removed`);
|
|
186
|
+
if (counts.rebound > 0)
|
|
187
|
+
parts.push(`${counts.rebound} rebound`);
|
|
188
|
+
lines.push(`_${parts.join(", ")}._`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
lines.push("");
|
|
192
|
+
}
|
|
193
|
+
function appendNotes(lines, notes) {
|
|
194
|
+
if (!notes || notes.length === 0)
|
|
195
|
+
return;
|
|
196
|
+
lines.push("## Notes");
|
|
197
|
+
lines.push("");
|
|
198
|
+
for (const n of notes)
|
|
199
|
+
lines.push(`- ${n}`);
|
|
200
|
+
lines.push("");
|
|
201
|
+
}
|
|
202
|
+
// ============================================================================
|
|
203
|
+
// Helpers
|
|
204
|
+
// ============================================================================
|
|
205
|
+
function formatVersionRef(meta, versionId, isTo) {
|
|
206
|
+
if (meta?.is_head || versionId === "current") {
|
|
207
|
+
const date = meta?.created_at ? formatDate(meta.created_at) : "current state";
|
|
208
|
+
const user = meta?.user_handle ? ` by ${meta.user_handle}` : "";
|
|
209
|
+
return `Current state (last modified ${date}${user})`;
|
|
210
|
+
}
|
|
211
|
+
if (!meta) {
|
|
212
|
+
return `\`${versionId}\` _(metadata not available)_`;
|
|
213
|
+
}
|
|
214
|
+
const label = meta.label && meta.label.trim() !== "" ? `"${meta.label}"` : "_(unlabeled)_";
|
|
215
|
+
const date = meta.created_at ? formatDate(meta.created_at) : "";
|
|
216
|
+
const user = meta.user_handle ? `by ${meta.user_handle}` : "";
|
|
217
|
+
const parts = [label, date, user].filter(Boolean).join(" β ");
|
|
218
|
+
return `${parts} \`${versionId}\``;
|
|
219
|
+
}
|
|
220
|
+
function formatSummaryLine(input) {
|
|
221
|
+
const p = input.page_structure.summary;
|
|
222
|
+
const componentCount = input.scoped_nodes?.filter((n) => n.change_count > 0).length ?? 0;
|
|
223
|
+
const totalComponentChanges = input.scoped_nodes?.reduce((acc, n) => acc + n.change_count, 0) ?? 0;
|
|
224
|
+
const parts = [];
|
|
225
|
+
if (p.added > 0)
|
|
226
|
+
parts.push(`${p.added} page${p.added === 1 ? "" : "s"} added`);
|
|
227
|
+
if (p.removed > 0)
|
|
228
|
+
parts.push(`${p.removed} page${p.removed === 1 ? "" : "s"} removed`);
|
|
229
|
+
if (p.renamed > 0)
|
|
230
|
+
parts.push(`${p.renamed} page${p.renamed === 1 ? "" : "s"} renamed`);
|
|
231
|
+
if (componentCount > 0) {
|
|
232
|
+
parts.push(`${componentCount} component${componentCount === 1 ? "" : "s"} with ${totalComponentChanges} change${totalComponentChanges === 1 ? "" : "s"}`);
|
|
233
|
+
}
|
|
234
|
+
if (parts.length === 0)
|
|
235
|
+
return "_No structural changes detected._";
|
|
236
|
+
return parts.join("; ") + ".";
|
|
237
|
+
}
|
|
238
|
+
function computeSpanDays(fromIso, toIso) {
|
|
239
|
+
if (!fromIso || !toIso)
|
|
240
|
+
return null;
|
|
241
|
+
const from = new Date(fromIso).getTime();
|
|
242
|
+
const to = new Date(toIso).getTime();
|
|
243
|
+
if (isNaN(from) || isNaN(to))
|
|
244
|
+
return null;
|
|
245
|
+
return Math.max(0, Math.round((to - from) / (1000 * 60 * 60 * 24)));
|
|
246
|
+
}
|
|
247
|
+
function formatDate(iso) {
|
|
248
|
+
// ISO 8601 is fine as-is for release notes; trim time if it's midnight UTC for cleaner output
|
|
249
|
+
const d = new Date(iso);
|
|
250
|
+
if (isNaN(d.getTime()))
|
|
251
|
+
return iso;
|
|
252
|
+
return d.toISOString().slice(0, 10);
|
|
253
|
+
}
|
|
254
|
+
function escapeMd(s) {
|
|
255
|
+
// Escape characters that would break markdown. Keep light β release notes
|
|
256
|
+
// should be readable, not over-escaped.
|
|
257
|
+
return s.replace(/([\\`*_{}\[\]<>])/g, "\\$1");
|
|
258
|
+
}
|
|
259
|
+
function stringifyValue(v) {
|
|
260
|
+
if (v === undefined)
|
|
261
|
+
return "?"; // standard mode strips defaults; render placeholder
|
|
262
|
+
if (v === null)
|
|
263
|
+
return "null";
|
|
264
|
+
if (typeof v === "string")
|
|
265
|
+
return v;
|
|
266
|
+
try {
|
|
267
|
+
return JSON.stringify(v);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return String(v);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function hasAnyPropChanges(s) {
|
|
274
|
+
return s.added > 0 || s.removed > 0 || s.type_changed > 0 || s.default_changed > 0;
|
|
275
|
+
}
|