@hauptsache.net/clickup-mcp 1.6.2 → 1.7.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.
@@ -5,6 +5,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.convertClickUpTextItemsToToolCallResult = convertClickUpTextItemsToToolCallResult;
7
7
  exports.convertMarkdownToToolCallResult = convertMarkdownToToolCallResult;
8
+ exports.buildImageFragment = buildImageFragment;
9
+ exports.parseClickUpTaskUrl = parseClickUpTaskUrl;
10
+ exports.normalizeImageDestinations = normalizeImageDestinations;
11
+ exports.collectMarkdownImageSources = collectMarkdownImageSources;
12
+ exports.rewriteMarkdownImageUrls = rewriteMarkdownImageUrls;
8
13
  exports.convertMarkdownToClickUpBlocks = convertMarkdownToClickUpBlocks;
9
14
  const data_uri_1 = require("./shared/data-uri");
10
15
  const unified_1 = require("unified");
@@ -31,6 +36,18 @@ function extractThumbnailsFromDataAttachment(attributes) {
31
36
  return {};
32
37
  }
33
38
  }
39
+ /**
40
+ * Render an image reference as markdown, escaping whatever would break the syntax.
41
+ *
42
+ * Reading and writing use the same markdown here on purpose: it lets an agent feed a
43
+ * comment it just read straight back into editComment without losing the images.
44
+ */
45
+ function toMarkdownImage(alt, url) {
46
+ const safeAlt = alt.replace(/[\[\]\r\n]/g, " ").trim();
47
+ // Angle brackets let a URL with spaces or parentheses survive the round trip
48
+ const safeUrl = /[\s()]/.test(url) ? `<${url}>` : url;
49
+ return `![${safeAlt}](${safeUrl})`;
50
+ }
34
51
  /**
35
52
  * Process an array of ClickUp text items into a structured content format
36
53
  * that includes both text and images in their original sequence
@@ -45,11 +62,24 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
45
62
  // Track current formatting state to avoid unnecessary close/reopen
46
63
  let activeBold = false;
47
64
  let activeItalic = false;
65
+ let activeStrike = false;
48
66
  let activeCode = false;
67
+ // ClickUp emits one '\n' fragment with a code-block attribute per code LINE;
68
+ // consecutive ones belong to the same fenced block when read back as markdown.
69
+ let inCodeBlock = false;
70
+ let currentFenceLang = '';
71
+ const closeCodeFence = () => {
72
+ if (inCodeBlock) {
73
+ currentTextBlock += '```\n';
74
+ inCodeBlock = false;
75
+ currentFenceLang = '';
76
+ }
77
+ };
49
78
  for (let i = 0; i < textItems.length; i++) {
50
79
  const item = textItems[i];
51
80
  // Handle image items
52
81
  if (item.type === "image" && item.image && item.image.url) {
82
+ closeCodeFence();
53
83
  const imageFileName = item.image.name || item.image.title || "image";
54
84
  const imageUrl = item.image.url;
55
85
  const altText = item.text || imageFileName;
@@ -80,8 +110,10 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
80
110
  }
81
111
  continue;
82
112
  }
83
- // Add image URL reference inline to current text block
84
- currentTextBlock += `\nImage: ${imageFileName} - ${imageUrl}`;
113
+ // Reference the image in the same markdown syntax the write tools accept, so a
114
+ // comment read here can be handed back to editComment unchanged and keep its
115
+ // images - an existing ClickUp attachment URL is re-embedded without re-uploading.
116
+ currentTextBlock += `\n${toMarkdownImage(altText, imageUrl)}`;
85
117
  // Get working thumbnail URLs from data-attachment if available
86
118
  const extractedThumbnails = extractThumbnailsFromDataAttachment(item.attributes);
87
119
  // Determine best thumbnail URLs (prefer extracted over API thumbnails)
@@ -114,6 +146,27 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
114
146
  else if (typeof item.text === "string") {
115
147
  // Check if this is a newline with block formatting (header, blockquote, list)
116
148
  if (item.text === '\n' && item.attributes) {
149
+ // Code block formatting: append this line to the open fence (or open one).
150
+ // The attribute value carries the language, either directly or nested as
151
+ // {'code-block': lang}; 'plain' means no language.
152
+ if (item.attributes['code-block']) {
153
+ const rawLang = item.attributes['code-block'];
154
+ const lang = typeof rawLang === 'string' ? rawLang : rawLang?.['code-block'] ?? '';
155
+ const fenceLang = lang === 'plain' ? '' : lang;
156
+ if (inCodeBlock && fenceLang !== currentFenceLang) {
157
+ closeCodeFence();
158
+ }
159
+ if (!inCodeBlock) {
160
+ currentTextBlock += '```' + fenceLang + '\n';
161
+ inCodeBlock = true;
162
+ currentFenceLang = fenceLang;
163
+ }
164
+ currentTextBlock += currentLine + '\n';
165
+ currentLine = "";
166
+ continue;
167
+ }
168
+ // Any other line terminator ends a code block
169
+ closeCodeFence();
117
170
  // Header formatting
118
171
  if (item.attributes.header) {
119
172
  const level = item.attributes.header;
@@ -144,17 +197,8 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
144
197
  break;
145
198
  }
146
199
  }
147
- // Code block formatting
148
- else if (item.attributes['code-block']) {
149
- // Wrap the current line in code block markers
150
- currentLine = '```\n' + currentLine + '\n```';
151
- }
152
200
  // Add formatted line to text block
153
- currentTextBlock += currentLine;
154
- // Add newline unless it's code block (already has newlines)
155
- if (!item.attributes['code-block']) {
156
- currentTextBlock += '\n';
157
- }
201
+ currentTextBlock += currentLine + '\n';
158
202
  currentLine = ""; // Reset for next line
159
203
  continue;
160
204
  }
@@ -163,15 +207,18 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
163
207
  // Determine current and next formatting state
164
208
  const hasBold = item.attributes?.bold === true;
165
209
  const hasItalic = item.attributes?.italic === true;
210
+ const hasStrike = item.attributes?.strike === true;
166
211
  const hasLink = item.attributes?.link;
167
212
  // Look ahead to next non-newline block
168
213
  let nextHasBold = false;
169
214
  let nextHasItalic = false;
215
+ let nextHasStrike = false;
170
216
  for (let j = i + 1; j < textItems.length; j++) {
171
217
  const nextItem = textItems[j];
172
218
  if (nextItem.text !== '\n' || !nextItem.attributes) {
173
219
  nextHasBold = nextItem.attributes?.bold === true;
174
220
  nextHasItalic = nextItem.attributes?.italic === true;
221
+ nextHasStrike = nextItem.attributes?.strike === true;
175
222
  break;
176
223
  }
177
224
  }
@@ -181,14 +228,20 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
181
228
  prefix += "**";
182
229
  if (hasItalic && !activeItalic)
183
230
  prefix += "*";
231
+ if (hasStrike && !activeStrike)
232
+ prefix += "~~";
184
233
  // Build suffix (close formatting that won't continue)
185
234
  let suffix = "";
235
+ if (hasStrike && !nextHasStrike)
236
+ suffix += "~~";
186
237
  if (hasItalic && !nextHasItalic)
187
238
  suffix += "*";
188
239
  if (hasBold && !nextHasBold)
189
240
  suffix += "**";
190
241
  // Close formatting that's active but not in this block
191
242
  let closingPrefix = "";
243
+ if (activeStrike && !hasStrike)
244
+ closingPrefix += "~~";
192
245
  if (activeBold && !hasBold)
193
246
  closingPrefix += "**";
194
247
  if (activeItalic && !hasItalic)
@@ -197,6 +250,7 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
197
250
  // Update state
198
251
  activeBold = hasBold && nextHasBold;
199
252
  activeItalic = hasItalic && nextHasItalic;
253
+ activeStrike = hasStrike && nextHasStrike;
200
254
  // Link formatting (wraps everything)
201
255
  if (hasLink) {
202
256
  formattedText = `[${formattedText}](${hasLink})`;
@@ -208,6 +262,7 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
208
262
  // Add to current line (not text block yet)
209
263
  if (item.text === '\n') {
210
264
  // Plain newline without formatting
265
+ closeCodeFence();
211
266
  currentTextBlock += currentLine + '\n';
212
267
  currentLine = "";
213
268
  }
@@ -215,12 +270,18 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
215
270
  currentLine += formattedText;
216
271
  }
217
272
  }
273
+ // Task mentions render as the task URL so the reference survives the round trip:
274
+ // writing that URL back through addComment/editComment regenerates the mention.
275
+ else if (item.type === "task_mention" && item.task_mention?.task_id) {
276
+ currentLine += `https://app.clickup.com/t/${item.task_mention.task_id}`;
277
+ }
218
278
  // Handle other types of items like bookmarks or whatever clickup can think of
219
279
  else {
220
280
  currentTextBlock += JSON.stringify(item);
221
281
  }
222
282
  }
223
283
  // Add any remaining text
284
+ closeCodeFence();
224
285
  if (currentLine) {
225
286
  currentTextBlock += currentLine;
226
287
  }
@@ -288,9 +349,9 @@ function convertMarkdownToToolCallResult(markdownText, attachments) {
288
349
  // Check if this image URL exists in our attachments
289
350
  const attachment = attachmentMap.get(imageUrl);
290
351
  if (attachment) {
291
- // Add image URL reference inline to current text block
352
+ // Keep the markdown syntax, so the reference stays usable in a write call
292
353
  const imageFileName = altText || "image";
293
- currentTextBlock += `\nImage: ${imageFileName} - ${imageUrl}`;
354
+ currentTextBlock += `\n${toMarkdownImage(imageFileName, imageUrl)}`;
294
355
  // Only create image_metadata if we have at least one thumbnail (never use original image)
295
356
  if (attachment.thumbnail_large || attachment.thumbnail_medium || attachment.thumbnail_small) {
296
357
  // Push accumulated text (including image URL) as a text block
@@ -380,14 +441,132 @@ function extractFileTypeFromUrl(url) {
380
441
  return null;
381
442
  return filename.substring(lastDot + 1);
382
443
  }
444
+ /**
445
+ * Build the image fragment ClickUp needs to render an inline image in a comment.
446
+ * `title`/`text` carry the caption; the rest is copied straight from the upload response.
447
+ */
448
+ function buildImageFragment(attachment, caption) {
449
+ const label = caption || attachment.name || 'image';
450
+ const fragment = {
451
+ type: 'image',
452
+ text: label,
453
+ image: {
454
+ id: attachment.id,
455
+ name: attachment.name,
456
+ title: label,
457
+ extension: attachment.extension,
458
+ url: attachment.url,
459
+ thumbnail_small: attachment.thumbnail_small,
460
+ thumbnail_medium: attachment.thumbnail_medium,
461
+ thumbnail_large: attachment.thumbnail_large,
462
+ width: attachment.width,
463
+ height: attachment.height,
464
+ },
465
+ };
466
+ if (caption) {
467
+ fragment.attributes = { alt: caption };
468
+ }
469
+ return fragment;
470
+ }
471
+ /**
472
+ * Matches a plain ClickUp task URL, with or without the team segment:
473
+ * https://app.clickup.com/t/86cb3t6t2 or https://app.clickup.com/t/4500611/86cb3t6t2
474
+ *
475
+ * Deliberately narrow: custom task IDs (PREFIX-123) and URLs carrying a query or
476
+ * fragment (e.g. ?comment=... deep links) do NOT match, because a mention would
477
+ * either not resolve or lose the anchor - those stay ordinary links.
478
+ */
479
+ const CLICKUP_TASK_URL_PATTERN = /^https?:\/\/app\.clickup\.com\/t\/(?:\d+\/)?([a-z0-9]{6,12})\/?$/;
480
+ /**
481
+ * Extract the task ID from a ClickUp task URL, or null if it is not one.
482
+ */
483
+ function parseClickUpTaskUrl(url) {
484
+ const match = url.match(CLICKUP_TASK_URL_PATTERN);
485
+ return match ? match[1] : null;
486
+ }
487
+ /**
488
+ * Wrap image destinations that contain spaces in angle brackets.
489
+ *
490
+ * CommonMark rejects a bare destination with spaces, so `![x](/tmp/Screen Shot.png)`
491
+ * is not an image at all - it would silently stay literal text and never be uploaded.
492
+ * Screenshot filenames have spaces constantly ("Screenshot 2026-07-27 at 14.30.png"),
493
+ * so normalising to the `<...>` form is what makes the obvious thing work.
494
+ */
495
+ function normalizeImageDestinations(markdown) {
496
+ return markdown.replace(/!\[([^\]]*)\]\(([^)\n]*)\)/g, (match, alt, inner) => {
497
+ const trimmed = inner.trim();
498
+ // Already bracketed, or nothing to fix
499
+ if (trimmed.startsWith('<') || trimmed.includes('>')) {
500
+ return match;
501
+ }
502
+ // Split off an optional markdown title: dest "title" / 'title'
503
+ const titleMatch = trimmed.match(/^(.*?)(\s+(?:"[^"]*"|'[^']*'))$/s);
504
+ const dest = titleMatch ? titleMatch[1] : trimmed;
505
+ const title = titleMatch ? titleMatch[2] : '';
506
+ if (!dest || !/\s/.test(dest)) {
507
+ return match;
508
+ }
509
+ return `![${alt}](<${dest}>${title})`;
510
+ });
511
+ }
512
+ /**
513
+ * Collect every image reference in a markdown document, in document order.
514
+ * Callers use this to know what needs uploading before converting.
515
+ */
516
+ function collectMarkdownImageSources(markdown) {
517
+ const images = [];
518
+ try {
519
+ const tree = (0, unified_1.unified)()
520
+ .use(remark_parse_1.default)
521
+ .use(remark_gfm_1.default)
522
+ .parse(markdown);
523
+ const visit = (nodes) => {
524
+ for (const node of nodes) {
525
+ if (node.type === 'image' && typeof node.url === 'string') {
526
+ images.push({ src: node.url, alt: typeof node.alt === 'string' ? node.alt : '' });
527
+ }
528
+ else if (Array.isArray(node.children)) {
529
+ visit(node.children);
530
+ }
531
+ }
532
+ };
533
+ visit(tree.children);
534
+ }
535
+ catch (error) {
536
+ console.error('Failed to collect markdown images:', error);
537
+ }
538
+ return images;
539
+ }
540
+ /**
541
+ * Replace image sources in markdown with their uploaded ClickUp URLs.
542
+ *
543
+ * Used for task descriptions: `markdown_description` renders `![alt](url)` directly,
544
+ * so descriptions need no fragment handling - only the URL has to be swapped.
545
+ * Images without an upload keep their original source untouched.
546
+ */
547
+ function rewriteMarkdownImageUrls(markdown, attachmentsBySrc) {
548
+ if (attachmentsBySrc.size === 0) {
549
+ return markdown;
550
+ }
551
+ return markdown.replace(/!\[([^\]]*)\]\(\s*(<[^>]*>|[^)\s]+)([^)]*)\)/g, (match, alt, rawSrc, trailing) => {
552
+ const src = rawSrc.startsWith('<') && rawSrc.endsWith('>') ? rawSrc.slice(1, -1) : rawSrc;
553
+ const attachment = attachmentsBySrc.get(src);
554
+ if (!attachment) {
555
+ return match;
556
+ }
557
+ return `![${alt}](${attachment.url}${trailing})`;
558
+ });
559
+ }
383
560
  /**
384
561
  * Convert markdown text to ClickUp comment blocks format using remark
385
- * Supports: headers, bold, italic, code, links, lists, blockquotes, code blocks
562
+ * Supports: headers, bold, italic, code, links, lists, blockquotes, code blocks, images
386
563
  *
387
564
  * @param markdown The markdown text to convert
565
+ * @param attachmentsBySrc Uploaded attachments keyed by the markdown `src` they came from.
566
+ * Images without an entry degrade to a link so their information is not lost.
388
567
  * @returns Array of ClickUp comment blocks
389
568
  */
390
- function convertMarkdownToClickUpBlocks(markdown) {
569
+ function convertMarkdownToClickUpBlocks(markdown, attachmentsBySrc) {
391
570
  const blocks = [];
392
571
  try {
393
572
  // Parse the entire markdown document using remark with GFM support (for task lists)
@@ -396,7 +575,7 @@ function convertMarkdownToClickUpBlocks(markdown) {
396
575
  .use(remark_gfm_1.default)
397
576
  .parse(markdown);
398
577
  // Walk the tree recursively
399
- walkMdastNodes(tree.children, {}, blocks);
578
+ walkMdastNodes(tree.children, {}, blocks, 0, attachmentsBySrc);
400
579
  }
401
580
  catch (error) {
402
581
  console.error('Failed to parse markdown:', error);
@@ -412,20 +591,20 @@ function convertMarkdownToClickUpBlocks(markdown) {
412
591
  * @param blocks Output array to append ClickUp blocks to
413
592
  * @param depth Nesting depth for lists (0 = top level, 1 = first nest, etc.)
414
593
  */
415
- function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
594
+ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0, attachmentsBySrc) {
416
595
  for (let i = 0; i < nodes.length; i++) {
417
596
  const node = nodes[i];
418
597
  const currentAttrs = { ...inheritedAttrs };
419
598
  switch (node.type) {
420
599
  case 'heading':
421
600
  // Process heading content with inline formatting
422
- walkPhrasingContent(node.children, currentAttrs, blocks);
601
+ walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
423
602
  // Add newline with header attribute
424
603
  blocks.push({ text: '\n', attributes: { header: node.depth } });
425
604
  break;
426
605
  case 'paragraph':
427
606
  // Process paragraph content with inline formatting
428
- walkPhrasingContent(node.children, currentAttrs, blocks);
607
+ walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
429
608
  // Add newline unless it's the last node
430
609
  if (i < nodes.length - 1) {
431
610
  blocks.push({ text: '\n', attributes: {} });
@@ -437,7 +616,7 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
437
616
  const blockquoteChildren = node.children;
438
617
  for (const child of blockquoteChildren) {
439
618
  if (child.type === 'paragraph') {
440
- walkPhrasingContent(child.children, currentAttrs, blocks);
619
+ walkPhrasingContent(child.children, currentAttrs, blocks, attachmentsBySrc);
441
620
  blocks.push({ text: '\n', attributes: { blockquote: {} } });
442
621
  }
443
622
  // Note: Other child types (heading, list) are not supported by ClickUp blockquotes
@@ -457,7 +636,7 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
457
636
  for (const itemChild of listItem.children) {
458
637
  if (itemChild.type === 'paragraph') {
459
638
  // Process paragraph content with inline formatting
460
- walkPhrasingContent(itemChild.children, currentAttrs, blocks);
639
+ walkPhrasingContent(itemChild.children, currentAttrs, blocks, attachmentsBySrc);
461
640
  // Add newline with list formatting and optional indent
462
641
  const listAttrs = {
463
642
  list: { list: finalListType }
@@ -470,7 +649,7 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
470
649
  }
471
650
  else if (itemChild.type === 'list') {
472
651
  // Nested list - recursively process with increased depth
473
- walkMdastNodes([itemChild], currentAttrs, blocks, depth + 1);
652
+ walkMdastNodes([itemChild], currentAttrs, blocks, depth + 1, attachmentsBySrc);
474
653
  }
475
654
  }
476
655
  }
@@ -479,34 +658,148 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
479
658
  // Code block
480
659
  const codeNode = node;
481
660
  if (codeNode.value) {
482
- blocks.push({ text: codeNode.value, attributes: {} });
483
- blocks.push({
484
- text: '\n',
485
- attributes: { 'code-block': { 'code-block': codeNode.lang || 'plain' } }
486
- });
661
+ pushCodeBlockLines(blocks, codeNode.value, codeNode.lang || 'plain');
487
662
  }
488
663
  break;
489
664
  case 'thematicBreak':
490
665
  // Horizontal rule - just add a line break
491
666
  blocks.push({ text: '\n', attributes: {} });
492
667
  break;
668
+ case 'table': {
669
+ // ClickUp comments cannot render tables (there is no table fragment in the
670
+ // comment format - unknown attributes are stored but render as plain text).
671
+ // Re-render the table as an aligned pipe table inside a code block so the
672
+ // information survives and stays readable in monospace.
673
+ const tableText = serializeTableAsAlignedPipes(node);
674
+ if (tableText) {
675
+ pushCodeBlockLines(blocks, tableText, 'plain');
676
+ }
677
+ break;
678
+ }
493
679
  default:
494
680
  // For any other block-level nodes, try to process children
495
681
  if ('children' in node && Array.isArray(node.children)) {
496
- walkMdastNodes(node.children, currentAttrs, blocks, depth);
682
+ walkMdastNodes(node.children, currentAttrs, blocks, depth, attachmentsBySrc);
683
+ }
684
+ else if ('value' in node && typeof node.value === 'string') {
685
+ // Last-resort safety net: never drop text content silently
686
+ blocks.push({ text: node.value, attributes: {} });
497
687
  }
498
688
  break;
499
689
  }
500
690
  }
501
691
  }
692
+ /**
693
+ * Emit a (possibly multi-line) code block the way ClickUp's Quill-based format
694
+ * expects it: block attributes apply per line, so EVERY line needs its own '\n'
695
+ * fragment carrying the code-block attribute. A single text fragment with embedded
696
+ * newlines renders only its last line as code - the rest degrades to plain text.
697
+ */
698
+ function pushCodeBlockLines(blocks, code, lang) {
699
+ for (const line of code.split('\n')) {
700
+ if (line) {
701
+ blocks.push({ text: line, attributes: {} });
702
+ }
703
+ blocks.push({
704
+ text: '\n',
705
+ attributes: { 'code-block': { 'code-block': lang } }
706
+ });
707
+ }
708
+ }
709
+ /**
710
+ * Serialize phrasing content back to compact markdown for use inside a code block.
711
+ * Inline formatting markers are kept so nothing is lost, even though a code block
712
+ * renders them literally.
713
+ */
714
+ function serializePhrasingToMarkdown(nodes) {
715
+ let out = '';
716
+ for (const node of nodes) {
717
+ switch (node.type) {
718
+ case 'text':
719
+ out += node.value;
720
+ break;
721
+ case 'strong':
722
+ out += `**${serializePhrasingToMarkdown(node.children)}**`;
723
+ break;
724
+ case 'emphasis':
725
+ out += `*${serializePhrasingToMarkdown(node.children)}*`;
726
+ break;
727
+ case 'delete':
728
+ out += `~~${serializePhrasingToMarkdown(node.children)}~~`;
729
+ break;
730
+ case 'inlineCode':
731
+ out += `\`${node.value}\``;
732
+ break;
733
+ case 'link':
734
+ out += `[${serializePhrasingToMarkdown(node.children)}](${node.url})`;
735
+ break;
736
+ case 'image':
737
+ out += node.alt || node.url;
738
+ break;
739
+ case 'break':
740
+ out += ' ';
741
+ break;
742
+ default:
743
+ if ('value' in node && typeof node.value === 'string') {
744
+ out += node.value;
745
+ }
746
+ else if ('children' in node && Array.isArray(node.children)) {
747
+ out += serializePhrasingToMarkdown(node.children);
748
+ }
749
+ break;
750
+ }
751
+ }
752
+ return out;
753
+ }
754
+ /**
755
+ * Render an mdast table as a column-aligned pipe table string.
756
+ */
757
+ function serializeTableAsAlignedPipes(table) {
758
+ const rows = table.children.map((row) => row.children.map((cell) => serializePhrasingToMarkdown(cell.children).replace(/\|/g, '\\|').trim()));
759
+ if (rows.length === 0) {
760
+ return '';
761
+ }
762
+ const colCount = Math.max(...rows.map((r) => r.length));
763
+ const widths = [];
764
+ for (let col = 0; col < colCount; col++) {
765
+ widths[col] = Math.max(3, ...rows.map((r) => (r[col] ?? '').length));
766
+ }
767
+ const renderRow = (cells) => '| ' + widths.map((w, col) => (cells[col] ?? '').padEnd(w)).join(' | ') + ' |';
768
+ const lines = [renderRow(rows[0])];
769
+ lines.push('| ' + widths.map((w) => '-'.repeat(w)).join(' | ') + ' |');
770
+ for (const row of rows.slice(1)) {
771
+ lines.push(renderRow(row));
772
+ }
773
+ return lines.join('\n');
774
+ }
502
775
  /**
503
776
  * Recursively walk phrasing content (inline nodes) and build ClickUp blocks
504
777
  * Accumulates formatting attributes from parent nodes
505
778
  */
506
- function walkPhrasingContent(nodes, inheritedAttrs, blocks) {
779
+ function walkPhrasingContent(nodes, inheritedAttrs, blocks, attachmentsBySrc) {
507
780
  for (const node of nodes) {
508
781
  const currentAttrs = { ...inheritedAttrs };
509
782
  switch (node.type) {
783
+ case 'image': {
784
+ // An image node has neither `value` nor `children`, so without this case it
785
+ // would fall through to `default` and vanish silently.
786
+ const attachment = attachmentsBySrc?.get(node.url);
787
+ const caption = node.alt || '';
788
+ if (attachment) {
789
+ blocks.push(buildImageFragment(attachment, caption));
790
+ }
791
+ else {
792
+ // Nothing was uploaded for this source - degrade to a link rather than
793
+ // dropping the reference, so the information survives.
794
+ const label = caption || node.url;
795
+ const isEmbeddable = /^https?:\/\//i.test(node.url);
796
+ blocks.push({
797
+ text: label,
798
+ attributes: isEmbeddable ? { ...currentAttrs, link: node.url } : currentAttrs,
799
+ });
800
+ }
801
+ break;
802
+ }
510
803
  case 'text':
511
804
  // Plain text node
512
805
  if (node.value) {
@@ -519,12 +812,17 @@ function walkPhrasingContent(nodes, inheritedAttrs, blocks) {
519
812
  case 'strong':
520
813
  // Bold text - recurse with bold attribute
521
814
  currentAttrs.bold = true;
522
- walkPhrasingContent(node.children, currentAttrs, blocks);
815
+ walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
523
816
  break;
524
817
  case 'emphasis':
525
818
  // Italic text - recurse with italic attribute
526
819
  currentAttrs.italic = true;
527
- walkPhrasingContent(node.children, currentAttrs, blocks);
820
+ walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
821
+ break;
822
+ case 'delete':
823
+ // GFM strikethrough (~~text~~) - ClickUp renders this via the strike attribute
824
+ currentAttrs.strike = true;
825
+ walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
528
826
  break;
529
827
  case 'inlineCode':
530
828
  // Inline code
@@ -536,11 +834,20 @@ function walkPhrasingContent(nodes, inheritedAttrs, blocks) {
536
834
  });
537
835
  }
538
836
  break;
539
- case 'link':
540
- // Link - recurse with link attribute
837
+ case 'link': {
838
+ // A link to a ClickUp task becomes a real task mention, matching what the
839
+ // ClickUp UI does when a task URL is pasted. The mention renders the live
840
+ // task name, so any custom link text is intentionally replaced by it.
841
+ const mentionedTaskId = parseClickUpTaskUrl(node.url);
842
+ if (mentionedTaskId) {
843
+ blocks.push({ type: 'task_mention', task_mention: { task_id: mentionedTaskId } });
844
+ break;
845
+ }
846
+ // Ordinary link - recurse with link attribute
541
847
  currentAttrs.link = node.url;
542
- walkPhrasingContent(node.children, currentAttrs, blocks);
848
+ walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
543
849
  break;
850
+ }
544
851
  case 'break':
545
852
  // Line break - add as plain text
546
853
  blocks.push({ text: '\n', attributes: {} });
@@ -555,7 +862,7 @@ function walkPhrasingContent(nodes, inheritedAttrs, blocks) {
555
862
  }
556
863
  else if ('children' in node && Array.isArray(node.children)) {
557
864
  // Recurse into children for other container nodes
558
- walkPhrasingContent(node.children, currentAttrs, blocks);
865
+ walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
559
866
  }
560
867
  break;
561
868
  }