@muloka/figma-console-mcp 0.5.0 → 0.5.2

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 (111) hide show
  1. package/README.md +3 -1
  2. package/dist/core/port-discovery.d.ts +9 -0
  3. package/dist/core/port-discovery.d.ts.map +1 -1
  4. package/dist/core/port-discovery.js +40 -14
  5. package/dist/core/port-discovery.js.map +1 -1
  6. package/dist/core/websocket-connector.d.ts +2 -1
  7. package/dist/core/websocket-connector.d.ts.map +1 -1
  8. package/dist/core/websocket-connector.js +17 -2
  9. package/dist/core/websocket-connector.js.map +1 -1
  10. package/dist/core/websocket-server.d.ts +8 -1
  11. package/dist/core/websocket-server.d.ts.map +1 -1
  12. package/dist/core/websocket-server.js +18 -2
  13. package/dist/core/websocket-server.js.map +1 -1
  14. package/dist/core/write-tools.d.ts +3 -1
  15. package/dist/core/write-tools.d.ts.map +1 -1
  16. package/dist/core/write-tools.js +34 -14
  17. package/dist/core/write-tools.js.map +1 -1
  18. package/dist/local.d.ts +2 -1
  19. package/dist/local.d.ts.map +1 -1
  20. package/dist/local.js +10 -14
  21. package/dist/local.js.map +1 -1
  22. package/package.json +1 -1
  23. package/dist/apps/design-system-dashboard/mcp-app.html +0 -521
  24. package/dist/apps/token-browser/mcp-app.html +0 -410
  25. package/dist/cloudflare/apps/design-system-dashboard/scoring/accessibility.js +0 -277
  26. package/dist/cloudflare/apps/design-system-dashboard/scoring/component-metadata.js +0 -357
  27. package/dist/cloudflare/apps/design-system-dashboard/scoring/consistency.js +0 -341
  28. package/dist/cloudflare/apps/design-system-dashboard/scoring/coverage.js +0 -230
  29. package/dist/cloudflare/apps/design-system-dashboard/scoring/engine.js +0 -92
  30. package/dist/cloudflare/apps/design-system-dashboard/scoring/naming-semantics.js +0 -308
  31. package/dist/cloudflare/apps/design-system-dashboard/scoring/token-architecture.js +0 -349
  32. package/dist/cloudflare/apps/design-system-dashboard/scoring/types.js +0 -40
  33. package/dist/cloudflare/apps/design-system-dashboard/server.js +0 -159
  34. package/dist/cloudflare/apps/token-browser/server.js +0 -136
  35. package/dist/cloudflare/browser-manager.js +0 -157
  36. package/dist/cloudflare/core/accessibility-tools.js +0 -361
  37. package/dist/cloudflare/core/annotation-tools.js +0 -230
  38. package/dist/cloudflare/core/cloud-websocket-connector.js +0 -419
  39. package/dist/cloudflare/core/cloud-websocket-relay.js +0 -198
  40. package/dist/cloudflare/core/comment-tools.js +0 -292
  41. package/dist/cloudflare/core/config.js +0 -153
  42. package/dist/cloudflare/core/console-monitor.js +0 -427
  43. package/dist/cloudflare/core/deep-component-tools.js +0 -128
  44. package/dist/cloudflare/core/design-code-tools.js +0 -2793
  45. package/dist/cloudflare/core/design-system-manifest.js +0 -265
  46. package/dist/cloudflare/core/design-system-tools.js +0 -882
  47. package/dist/cloudflare/core/diagnose-tool.js +0 -100
  48. package/dist/cloudflare/core/diff/changelog-formatter.js +0 -275
  49. package/dist/cloudflare/core/diff/diff-engine.js +0 -334
  50. package/dist/cloudflare/core/diff/property-compare.js +0 -36
  51. package/dist/cloudflare/core/diff/version-cache.js +0 -74
  52. package/dist/cloudflare/core/enrichment/enrichment-service.js +0 -374
  53. package/dist/cloudflare/core/enrichment/index.js +0 -7
  54. package/dist/cloudflare/core/enrichment/relationship-mapper.js +0 -351
  55. package/dist/cloudflare/core/enrichment/style-resolver.js +0 -346
  56. package/dist/cloudflare/core/figjam-tools.js +0 -547
  57. package/dist/cloudflare/core/figma-api.js +0 -509
  58. package/dist/cloudflare/core/figma-connector.js +0 -7
  59. package/dist/cloudflare/core/figma-reconstruction-spec.js +0 -402
  60. package/dist/cloudflare/core/figma-style-extractor.js +0 -311
  61. package/dist/cloudflare/core/figma-tools.js +0 -3353
  62. package/dist/cloudflare/core/identity.js +0 -96
  63. package/dist/cloudflare/core/library-tools.js +0 -580
  64. package/dist/cloudflare/core/logger.js +0 -53
  65. package/dist/cloudflare/core/port-discovery.js +0 -935
  66. package/dist/cloudflare/core/resolve-package-root.js +0 -11
  67. package/dist/cloudflare/core/slides-tools.js +0 -714
  68. package/dist/cloudflare/core/slot-tools.js +0 -334
  69. package/dist/cloudflare/core/snippet-injector.js +0 -96
  70. package/dist/cloudflare/core/tokens/alias-resolver.js +0 -205
  71. package/dist/cloudflare/core/tokens/config.js +0 -294
  72. package/dist/cloudflare/core/tokens/dialect.js +0 -232
  73. package/dist/cloudflare/core/tokens/figma-converter.js +0 -323
  74. package/dist/cloudflare/core/tokens/formatters/css-vars.js +0 -338
  75. package/dist/cloudflare/core/tokens/formatters/dtcg.js +0 -376
  76. package/dist/cloudflare/core/tokens/formatters/index.js +0 -45
  77. package/dist/cloudflare/core/tokens/formatters/json.js +0 -205
  78. package/dist/cloudflare/core/tokens/formatters/less.js +0 -4
  79. package/dist/cloudflare/core/tokens/formatters/scss.js +0 -258
  80. package/dist/cloudflare/core/tokens/formatters/stubs.js +0 -13
  81. package/dist/cloudflare/core/tokens/formatters/style-dictionary-v3.js +0 -213
  82. package/dist/cloudflare/core/tokens/formatters/tailwind-v3.js +0 -237
  83. package/dist/cloudflare/core/tokens/formatters/tailwind-v4.js +0 -335
  84. package/dist/cloudflare/core/tokens/formatters/tokens-studio.js +0 -256
  85. package/dist/cloudflare/core/tokens/formatters/ts-module.js +0 -198
  86. package/dist/cloudflare/core/tokens/index.js +0 -16
  87. package/dist/cloudflare/core/tokens/parsers/css-vars.js +0 -4
  88. package/dist/cloudflare/core/tokens/parsers/dtcg.js +0 -280
  89. package/dist/cloudflare/core/tokens/parsers/index.js +0 -138
  90. package/dist/cloudflare/core/tokens/parsers/json.js +0 -7
  91. package/dist/cloudflare/core/tokens/parsers/scss.js +0 -4
  92. package/dist/cloudflare/core/tokens/parsers/stubs.js +0 -20
  93. package/dist/cloudflare/core/tokens/parsers/style-dictionary-v3.js +0 -4
  94. package/dist/cloudflare/core/tokens/parsers/tailwind-v3.js +0 -4
  95. package/dist/cloudflare/core/tokens/parsers/tailwind-v4.js +0 -4
  96. package/dist/cloudflare/core/tokens/parsers/tokens-studio.js +0 -4
  97. package/dist/cloudflare/core/tokens/schemas.js +0 -152
  98. package/dist/cloudflare/core/tokens/transforms/color.js +0 -12
  99. package/dist/cloudflare/core/tokens/transforms/index.js +0 -29
  100. package/dist/cloudflare/core/tokens/transforms/size.js +0 -7
  101. package/dist/cloudflare/core/tokens/types.js +0 -18
  102. package/dist/cloudflare/core/tokens-tools.js +0 -1788
  103. package/dist/cloudflare/core/types/design-code.js +0 -4
  104. package/dist/cloudflare/core/types/enriched.js +0 -5
  105. package/dist/cloudflare/core/types/index.js +0 -4
  106. package/dist/cloudflare/core/variable-resolver.js +0 -85
  107. package/dist/cloudflare/core/version-tools.js +0 -1199
  108. package/dist/cloudflare/core/websocket-connector.js +0 -436
  109. package/dist/cloudflare/core/websocket-server.js +0 -1009
  110. package/dist/cloudflare/core/write-tools.js +0 -2506
  111. 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
- }