@muloka/figma-console-mcp 0.4.0 → 0.5.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.
Files changed (109) hide show
  1. package/README.md +7 -3
  2. package/dist/core/websocket-connector.d.ts +2 -1
  3. package/dist/core/websocket-connector.d.ts.map +1 -1
  4. package/dist/core/websocket-connector.js +17 -2
  5. package/dist/core/websocket-connector.js.map +1 -1
  6. package/dist/core/websocket-server.d.ts +8 -1
  7. package/dist/core/websocket-server.d.ts.map +1 -1
  8. package/dist/core/websocket-server.js +19 -3
  9. package/dist/core/websocket-server.js.map +1 -1
  10. package/dist/core/write-tools.d.ts +3 -1
  11. package/dist/core/write-tools.d.ts.map +1 -1
  12. package/dist/core/write-tools.js +98 -23
  13. package/dist/core/write-tools.js.map +1 -1
  14. package/dist/local.d.ts +2 -1
  15. package/dist/local.d.ts.map +1 -1
  16. package/dist/local.js +5 -4
  17. package/dist/local.js.map +1 -1
  18. package/figma-desktop-bridge/code.js +57 -27
  19. package/figma-desktop-bridge/ui.html +10 -2
  20. package/package.json +1 -1
  21. package/dist/apps/design-system-dashboard/mcp-app.html +0 -521
  22. package/dist/apps/token-browser/mcp-app.html +0 -410
  23. package/dist/cloudflare/apps/design-system-dashboard/scoring/accessibility.js +0 -277
  24. package/dist/cloudflare/apps/design-system-dashboard/scoring/component-metadata.js +0 -357
  25. package/dist/cloudflare/apps/design-system-dashboard/scoring/consistency.js +0 -341
  26. package/dist/cloudflare/apps/design-system-dashboard/scoring/coverage.js +0 -230
  27. package/dist/cloudflare/apps/design-system-dashboard/scoring/engine.js +0 -92
  28. package/dist/cloudflare/apps/design-system-dashboard/scoring/naming-semantics.js +0 -308
  29. package/dist/cloudflare/apps/design-system-dashboard/scoring/token-architecture.js +0 -349
  30. package/dist/cloudflare/apps/design-system-dashboard/scoring/types.js +0 -40
  31. package/dist/cloudflare/apps/design-system-dashboard/server.js +0 -159
  32. package/dist/cloudflare/apps/token-browser/server.js +0 -136
  33. package/dist/cloudflare/browser-manager.js +0 -157
  34. package/dist/cloudflare/core/accessibility-tools.js +0 -361
  35. package/dist/cloudflare/core/annotation-tools.js +0 -230
  36. package/dist/cloudflare/core/cloud-websocket-connector.js +0 -419
  37. package/dist/cloudflare/core/cloud-websocket-relay.js +0 -198
  38. package/dist/cloudflare/core/comment-tools.js +0 -292
  39. package/dist/cloudflare/core/config.js +0 -153
  40. package/dist/cloudflare/core/console-monitor.js +0 -427
  41. package/dist/cloudflare/core/deep-component-tools.js +0 -128
  42. package/dist/cloudflare/core/design-code-tools.js +0 -2793
  43. package/dist/cloudflare/core/design-system-manifest.js +0 -265
  44. package/dist/cloudflare/core/design-system-tools.js +0 -882
  45. package/dist/cloudflare/core/diagnose-tool.js +0 -100
  46. package/dist/cloudflare/core/diff/changelog-formatter.js +0 -275
  47. package/dist/cloudflare/core/diff/diff-engine.js +0 -334
  48. package/dist/cloudflare/core/diff/property-compare.js +0 -36
  49. package/dist/cloudflare/core/diff/version-cache.js +0 -74
  50. package/dist/cloudflare/core/enrichment/enrichment-service.js +0 -374
  51. package/dist/cloudflare/core/enrichment/index.js +0 -7
  52. package/dist/cloudflare/core/enrichment/relationship-mapper.js +0 -351
  53. package/dist/cloudflare/core/enrichment/style-resolver.js +0 -346
  54. package/dist/cloudflare/core/figjam-tools.js +0 -547
  55. package/dist/cloudflare/core/figma-api.js +0 -509
  56. package/dist/cloudflare/core/figma-connector.js +0 -7
  57. package/dist/cloudflare/core/figma-reconstruction-spec.js +0 -402
  58. package/dist/cloudflare/core/figma-style-extractor.js +0 -311
  59. package/dist/cloudflare/core/figma-tools.js +0 -3353
  60. package/dist/cloudflare/core/identity.js +0 -96
  61. package/dist/cloudflare/core/library-tools.js +0 -580
  62. package/dist/cloudflare/core/logger.js +0 -53
  63. package/dist/cloudflare/core/port-discovery.js +0 -935
  64. package/dist/cloudflare/core/resolve-package-root.js +0 -11
  65. package/dist/cloudflare/core/slides-tools.js +0 -714
  66. package/dist/cloudflare/core/slot-tools.js +0 -334
  67. package/dist/cloudflare/core/snippet-injector.js +0 -96
  68. package/dist/cloudflare/core/tokens/alias-resolver.js +0 -205
  69. package/dist/cloudflare/core/tokens/config.js +0 -294
  70. package/dist/cloudflare/core/tokens/dialect.js +0 -232
  71. package/dist/cloudflare/core/tokens/figma-converter.js +0 -323
  72. package/dist/cloudflare/core/tokens/formatters/css-vars.js +0 -338
  73. package/dist/cloudflare/core/tokens/formatters/dtcg.js +0 -376
  74. package/dist/cloudflare/core/tokens/formatters/index.js +0 -45
  75. package/dist/cloudflare/core/tokens/formatters/json.js +0 -205
  76. package/dist/cloudflare/core/tokens/formatters/less.js +0 -4
  77. package/dist/cloudflare/core/tokens/formatters/scss.js +0 -258
  78. package/dist/cloudflare/core/tokens/formatters/stubs.js +0 -13
  79. package/dist/cloudflare/core/tokens/formatters/style-dictionary-v3.js +0 -213
  80. package/dist/cloudflare/core/tokens/formatters/tailwind-v3.js +0 -237
  81. package/dist/cloudflare/core/tokens/formatters/tailwind-v4.js +0 -335
  82. package/dist/cloudflare/core/tokens/formatters/tokens-studio.js +0 -256
  83. package/dist/cloudflare/core/tokens/formatters/ts-module.js +0 -198
  84. package/dist/cloudflare/core/tokens/index.js +0 -16
  85. package/dist/cloudflare/core/tokens/parsers/css-vars.js +0 -4
  86. package/dist/cloudflare/core/tokens/parsers/dtcg.js +0 -280
  87. package/dist/cloudflare/core/tokens/parsers/index.js +0 -138
  88. package/dist/cloudflare/core/tokens/parsers/json.js +0 -7
  89. package/dist/cloudflare/core/tokens/parsers/scss.js +0 -4
  90. package/dist/cloudflare/core/tokens/parsers/stubs.js +0 -20
  91. package/dist/cloudflare/core/tokens/parsers/style-dictionary-v3.js +0 -4
  92. package/dist/cloudflare/core/tokens/parsers/tailwind-v3.js +0 -4
  93. package/dist/cloudflare/core/tokens/parsers/tailwind-v4.js +0 -4
  94. package/dist/cloudflare/core/tokens/parsers/tokens-studio.js +0 -4
  95. package/dist/cloudflare/core/tokens/schemas.js +0 -152
  96. package/dist/cloudflare/core/tokens/transforms/color.js +0 -12
  97. package/dist/cloudflare/core/tokens/transforms/index.js +0 -29
  98. package/dist/cloudflare/core/tokens/transforms/size.js +0 -7
  99. package/dist/cloudflare/core/tokens/types.js +0 -18
  100. package/dist/cloudflare/core/tokens-tools.js +0 -1788
  101. package/dist/cloudflare/core/types/design-code.js +0 -4
  102. package/dist/cloudflare/core/types/enriched.js +0 -5
  103. package/dist/cloudflare/core/types/index.js +0 -4
  104. package/dist/cloudflare/core/variable-resolver.js +0 -85
  105. package/dist/cloudflare/core/version-tools.js +0 -1199
  106. package/dist/cloudflare/core/websocket-connector.js +0 -436
  107. package/dist/cloudflare/core/websocket-server.js +0 -1009
  108. package/dist/cloudflare/core/write-tools.js +0 -2506
  109. package/dist/cloudflare/index.js +0 -3010
@@ -1,100 +0,0 @@
1
- /**
2
- * figma_diagnose — a designer-readable health check for figma-console-mcp.
3
- *
4
- * This is the one tool to point a confused user at. It self-identifies the
5
- * server, reports the transport / plugin / token state in plain language,
6
- * and explicitly disclaims any token or OAuth error a user might be seeing
7
- * in chat (those typically come from a different MCP). The response is
8
- * structured so an LLM is likely to surface the `report` field verbatim.
9
- */
10
- import { z } from "zod";
11
- import { MCP_NAME, withIdentity } from "./identity.js";
12
- function buildReport(opts) {
13
- const lines = [];
14
- lines.push(`# ${MCP_NAME} diagnostic`);
15
- lines.push("");
16
- lines.push(`Server version: ${opts.getServerVersion()}`);
17
- lines.push(`Mode: ${opts.mode === "local" ? "Local (stdio + WebSocket plugin bridge)" : "Cloud (Cloudflare Workers)"}`);
18
- lines.push("");
19
- const plugin = opts.getPluginState?.();
20
- lines.push("## Plugin connection");
21
- if (!plugin) {
22
- lines.push("- No WebSocket server (cloud mode runs without a local plugin bridge unless paired).");
23
- }
24
- else if (plugin.connected) {
25
- const portInfo = plugin.portFallbackFrom && plugin.port && plugin.port !== plugin.portFallbackFrom
26
- ? `port ${plugin.port} (fallback from ${plugin.portFallbackFrom})`
27
- : plugin.port
28
- ? `port ${plugin.port}`
29
- : "connected";
30
- lines.push(`- ✅ Desktop Bridge plugin connected on ${portInfo}.`);
31
- if (plugin.fileName) {
32
- lines.push(`- Active file: **${plugin.fileName}**${plugin.currentPage ? ` (page: ${plugin.currentPage})` : ""}.`);
33
- }
34
- if (plugin.editorType && plugin.editorType !== "figma") {
35
- lines.push(`- Editor type: ${plugin.editorType}.`);
36
- }
37
- if (plugin.pluginUpdateAvailable) {
38
- const bundledVersion = opts.getBundledPluginVersion?.() ?? opts.getServerVersion();
39
- lines.push(`- ⚠️ **Plugin update available**: the imported plugin${plugin.pluginVersion ? ` (v${plugin.pluginVersion})` : " (version unknown — very old)"} differs from the plugin files this server ships (v${bundledVersion}). Figma caches plugin files, so re-import it: Figma Desktop → Plugins → Development → Import plugin from manifest → select manifest.json. Until then, recently added or fixed plugin features may silently misbehave.`);
40
- }
41
- }
42
- else {
43
- lines.push(`- ⚠️ Desktop Bridge plugin not connected${plugin.port ? ` (server is listening on port ${plugin.port})` : ""}.`);
44
- lines.push("- To fix: open the Figma Desktop Bridge plugin in Figma Desktop. If it was already running, close and reopen it once.");
45
- }
46
- lines.push("");
47
- const token = opts.getTokenState?.();
48
- lines.push("## REST API token");
49
- if (!token) {
50
- lines.push("- Token state not available in this mode.");
51
- }
52
- else if (token.hasToken) {
53
- lines.push(`- ✅ Figma access token detected (source: ${token.source ?? "unknown"}). REST-based tools should work.`);
54
- }
55
- else {
56
- lines.push("- ⚠️ No Figma access token detected. Plugin-based tools still work; REST-only tools (file data, version history, image rendering) will return an auth error.");
57
- if (opts.mode === "local") {
58
- lines.push("- To enable REST tools: set FIGMA_ACCESS_TOKEN in your MCP client config. Generate a token at https://www.figma.com/developers/api#access-tokens.");
59
- }
60
- else {
61
- lines.push("- To enable REST tools: authenticate via your MCP client's OAuth flow or pass a Figma personal access token (figd_...) as a Bearer token.");
62
- }
63
- }
64
- lines.push("");
65
- lines.push("## If you're seeing token / OAuth errors in chat");
66
- lines.push(`If you see messages like "API token has expired" or "Bearer token has expired" and the response above does NOT show a token problem, the error is most likely coming from a **different MCP server**, not from ${MCP_NAME}.`);
67
- lines.push("");
68
- lines.push("Common cause: Figma's native MCP (often listed as just `Figma` in your MCP client) is also installed and its OAuth session expired. Check your MCP client's server list. Errors from those servers are not produced by figma-console-mcp.");
69
- lines.push("");
70
- return lines.join("\n");
71
- }
72
- export function registerDiagnoseTool(server, options) {
73
- server.tool("figma_diagnose", `Designer-readable health check for ${MCP_NAME}. Use this when something seems wrong — it self-identifies the server, reports plugin connection / file / token status in plain language, and disambiguates errors that may be coming from a different Figma-related MCP server. Recommended as the first step when a user reports a "token expired" or "plugin disconnected" issue.`, {
74
- verbose: z
75
- .boolean()
76
- .optional()
77
- .default(false)
78
- .describe("Include the raw structured state alongside the human-readable report."),
79
- }, async ({ verbose }) => {
80
- const report = buildReport(options);
81
- const details = verbose
82
- ? {
83
- plugin: options.getPluginState?.() ?? null,
84
- token: options.getTokenState?.() ?? null,
85
- }
86
- : undefined;
87
- const payload = withIdentity({
88
- report,
89
- ...(details ? { details } : {}),
90
- });
91
- return {
92
- content: [
93
- {
94
- type: "text",
95
- text: JSON.stringify(payload),
96
- },
97
- ],
98
- };
99
- });
100
- }
@@ -1,275 +0,0 @@
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
- }