slackmark 0.6.0 → 0.6.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.
package/dist/index.js CHANGED
@@ -958,7 +958,7 @@ var DeterministicPlainTextRenderer = class {
958
958
  parts.push(src[i] ?? "");
959
959
  i += 1;
960
960
  }
961
- const raw = parts.join("").replace(/!\[[^\]]{0,200}\]\([^)]{0,3000}\)/g, " ").replace(/\[([^\]]{0,200})\]\([^)]{0,3000}\)/g, "$1").replace(/[#>*_`~]/g, "").replace(/\s+/g, " ").trim();
961
+ const raw = slackEntitiesToPlain(parts.join("")).replace(/!\[[^\]]{0,200}\]\([^)]{0,3000}\)/g, " ").replace(/\[([^\]]{0,200})\]\([^)]{0,3000}\)/g, "$1").replace(/^[ \t]*#{1,6}[ \t]*/gm, "").replace(/^[ \t]*>[ \t]?/gm, "").replace(/[*_`~]/g, "").replace(/\\([\\`*_{}[\]()#+\-.!>~|])/g, "$1").replace(/\s+/g, " ").trim();
962
962
  return raw.length > 0 ? raw : " ";
963
963
  }
964
964
  renderFromBlocks(blocks) {
@@ -1003,7 +1003,37 @@ var DeterministicPlainTextRenderer = class {
1003
1003
  }
1004
1004
  };
1005
1005
  function stripMrkdwn(text) {
1006
- return text.replace(/<([^|>]{1,3000})\|([^>]{0,500})>/g, "$2").replace(/<([^>]{1,3000})>/g, "$1").replace(/[*_~`]/g, "");
1006
+ return slackEntitiesToPlain(text).replace(/[*_~`]/g, "");
1007
+ }
1008
+ /**
1009
+ * Slack entity syntax rendered the way {@link inlinesToPlain} renders the
1010
+ * equivalent structured element, so a mention reads the same whether it reached
1011
+ * the output as a `rich_text` element or as literal text in a `markdown` block.
1012
+ *
1013
+ * This has to run before markdown punctuation is stripped. `>` closes every one
1014
+ * of these forms and also marks a blockquote, and `#` opens a channel reference
1015
+ * and also a heading, so stripping those characters first leaves `<@U123` behind.
1016
+ */
1017
+ function slackEntitiesToPlain(text) {
1018
+ return text.replace(/<([^<>]{1,3000})>/g, (_match, body) => {
1019
+ const [target, label] = splitOnce(body, "|");
1020
+ if (target.startsWith("@")) return `@${target.slice(1)}`;
1021
+ if (target.startsWith("#")) return `#${target.slice(1)}`;
1022
+ if (target.startsWith("!")) return specialToPlain(target.slice(1), label);
1023
+ return label ?? target;
1024
+ });
1025
+ }
1026
+ /** `<!here>`, `<!subteam^S1|@x>`, `<!date^…|fallback>` and friends. */
1027
+ function specialToPlain(command, label) {
1028
+ const [name, argument] = splitOnce(command, "^");
1029
+ if (name === "subteam") return `@${argument ?? ""}`;
1030
+ if (name === "date") return label ?? command;
1031
+ return `@${name}`;
1032
+ }
1033
+ function splitOnce(value, separator) {
1034
+ const index = value.indexOf(separator);
1035
+ if (index === -1) return [value, void 0];
1036
+ return [value.slice(0, index), value.slice(index + separator.length)];
1007
1037
  }
1008
1038
  function richTextToPlain(elements) {
1009
1039
  const parts = [];
@@ -1370,6 +1400,38 @@ function parseNumberList(inner) {
1370
1400
  return inner.split(",").map((s) => s.trim()).filter((s) => s.length > 0).map((s) => Number(s));
1371
1401
  }
1372
1402
  //#endregion
1403
+ //#region src/adapters/block-to-plain-text.ts
1404
+ /**
1405
+ * Plain text for a block that had to be flattened into a single text run.
1406
+ *
1407
+ * `mdast-util-to-string` concatenates every descendant with no separator, so a
1408
+ * table collapses to "ab12" and sibling paragraphs run together as
1409
+ * "para onepara two". Flattening is already flagged as a degradation; the text
1410
+ * it leaves behind still has to be readable, so re-insert the separators the
1411
+ * structure carried.
1412
+ */
1413
+ function blockToPlainText(node) {
1414
+ if (node.type === "table") return node.children.map((row) => row.children.map((cell) => toString(cell)).join(" | ")).join("\n");
1415
+ if (node.type === "list") return node.children.map((item) => `• ${blockToPlainText(item)}`).join("\n");
1416
+ if (node.type === "blockquote" || node.type === "listItem" || node.type === "footnoteDefinition") return node.children.map((child) => blockToPlainText(child)).join("\n");
1417
+ return toString(node);
1418
+ }
1419
+ /**
1420
+ * Link and image URLs carried by a block that is about to be flattened.
1421
+ * {@link blockToPlainText} keeps only labels and alt text, so callers must
1422
+ * flag these before they disappear — silent omission is a bug even when the
1423
+ * surrounding flattening is itself already flagged.
1424
+ */
1425
+ function collectFlattenedUrls(node, into) {
1426
+ if (node === null || typeof node !== "object") return;
1427
+ const record = node;
1428
+ if ((record.type === "link" || record.type === "image") && typeof record.url === "string") into.push({
1429
+ nodeType: record.type,
1430
+ url: record.url
1431
+ });
1432
+ if (record.children !== void 0) for (const child of record.children) collectFlattenedUrls(child, into);
1433
+ }
1434
+ //#endregion
1373
1435
  //#region src/budget/operation-scope.ts
1374
1436
  function createOperationScope(options, stage) {
1375
1437
  validateOperationOptions(options);
@@ -1734,6 +1796,7 @@ var SlackMarkConverter = class {
1734
1796
  * operation context. The returned promise tracks underlying conversion work.
1735
1797
  */
1736
1798
  async convertWithContext(markdown, options, operation, receipts = new RecordingReceiptCollector()) {
1799
+ resolveConfig(this.defaultOptions, options, operation);
1737
1800
  const multi = await this.convertToMessagesWithContext(markdown, {
1738
1801
  ...options,
1739
1802
  overflow: OverflowMode.Degrade
@@ -1820,6 +1883,17 @@ var SlackMarkConverter = class {
1820
1883
  footnoteOrder.forEach((id, i) => footnoteNumbers.set(id, i + 1));
1821
1884
  const footnoteBlocks = buildFootnoteBlocks(footnoteOrder.map((id, i) => {
1822
1885
  const def = footnotes.get(id);
1886
+ if (def !== void 0) {
1887
+ const dropped = [];
1888
+ collectFlattenedUrls(def, dropped);
1889
+ for (const { nodeType, url } of dropped) degradations.add({
1890
+ nodeType,
1891
+ from: url,
1892
+ to: "label_only",
1893
+ reason: "Footnote body renders as plain context text; URL dropped",
1894
+ path: `footnote[${String(i + 1)}]`
1895
+ });
1896
+ }
1823
1897
  return {
1824
1898
  n: i + 1,
1825
1899
  text: def !== void 0 ? footnoteBodyText(def, footnoteNumbers).trim() : id
@@ -2026,6 +2100,19 @@ function collectRefs(tree, degradations) {
2026
2100
  };
2027
2101
  }
2028
2102
  /**
2103
+ * Separator inserted between the children of a flattened footnote body node.
2104
+ * Without these, sibling paragraphs and table cells run together as
2105
+ * "para onepara two" / "ab12".
2106
+ */
2107
+ const FOOTNOTE_CHILD_SEPARATORS = /* @__PURE__ */ new Map([
2108
+ ["footnoteDefinition", "\n"],
2109
+ ["blockquote", "\n"],
2110
+ ["list", "\n"],
2111
+ ["listItem", "\n"],
2112
+ ["table", "\n"],
2113
+ ["tableRow", " | "]
2114
+ ]);
2115
+ /**
2029
2116
  * Footnote definition body as plain text with nested footnote references
2030
2117
  * rendered as their `[n]` markers (mdast-util-to-string drops them entirely).
2031
2118
  */
@@ -2041,7 +2128,8 @@ function footnoteBodyText(node, numbers) {
2041
2128
  if (record.children !== void 0) {
2042
2129
  const parts = [];
2043
2130
  for (const child of record.children) parts.push(footnoteBodyText(child, numbers));
2044
- return parts.join("");
2131
+ const separator = record.type !== void 0 ? FOOTNOTE_CHILD_SEPARATORS.get(record.type) ?? "" : "";
2132
+ return parts.join(separator);
2045
2133
  }
2046
2134
  return "";
2047
2135
  }
@@ -2353,6 +2441,43 @@ function countHoistableImages(nodes, ctx) {
2353
2441
  }
2354
2442
  return count;
2355
2443
  }
2444
+ /**
2445
+ * Record images a link label swallows. Slack link elements carry plain text, and hoisting
2446
+ * would detach the image from its link, so only the alt text survives.
2447
+ */
2448
+ function flagImagesLostInLabel(nodes, ctx) {
2449
+ for (const node of nodes) switch (node.type) {
2450
+ case "image":
2451
+ addImageLostInLabel(node.url, ctx);
2452
+ break;
2453
+ case "imageReference":
2454
+ addImageLostInLabel(ctx.definitions.get(node.identifier.toLowerCase())?.url ?? node.identifier, ctx);
2455
+ break;
2456
+ case "strong":
2457
+ case "emphasis":
2458
+ case "delete":
2459
+ flagImagesLostInLabel(node.children, ctx);
2460
+ break;
2461
+ case "text":
2462
+ case "inlineCode":
2463
+ case "break":
2464
+ case "link":
2465
+ case "linkReference":
2466
+ case "footnoteReference":
2467
+ case "html":
2468
+ case "inlineMath": break;
2469
+ default: break;
2470
+ }
2471
+ }
2472
+ function addImageLostInLabel(url, ctx) {
2473
+ ctx.degradations.add({
2474
+ nodeType: "image",
2475
+ from: "image",
2476
+ to: "text",
2477
+ reason: `Image nested in a link is dropped; only its alt text survives (${url})`,
2478
+ path: ctx.path
2479
+ });
2480
+ }
2356
2481
  function truncateLabel(label, max) {
2357
2482
  if (label.length <= max) return {
2358
2483
  text: label,
@@ -2639,6 +2764,7 @@ function reassembleSlackLabeledLink(work, i, style) {
2639
2764
  function emitLink(node, ctx, style) {
2640
2765
  const url = node.url;
2641
2766
  const text = phrasingToPlain(node.children);
2767
+ flagImagesLostInLabel(node.children, ctx);
2642
2768
  const pipeIndex = url.indexOf("|");
2643
2769
  if (pipeIndex > 0 && HTTP_URL_RE.test(url) && text === url) {
2644
2770
  const realUrl = url.slice(0, pipeIndex);
@@ -2673,11 +2799,14 @@ function emitLink(node, ctx, style) {
2673
2799
  }
2674
2800
  function emitLinkReference(node, ctx, style) {
2675
2801
  const def = ctx.definitions.get(node.identifier.toLowerCase());
2676
- if (def === void 0) return [{
2677
- type: "text",
2678
- text: phrasingToPlain(node.children) || node.identifier,
2679
- style: styleOrUndefined(style)
2680
- }];
2802
+ if (def === void 0) {
2803
+ flagImagesLostInLabel(node.children, ctx);
2804
+ return [{
2805
+ type: "text",
2806
+ text: phrasingToPlain(node.children) || node.identifier,
2807
+ style: styleOrUndefined(style)
2808
+ }];
2809
+ }
2681
2810
  return emitLink({
2682
2811
  type: "link",
2683
2812
  url: def.url,
@@ -2914,13 +3043,14 @@ function flattenQuote(quote, ctx) {
2914
3043
  reason: "Nested list inside blockquote flattened",
2915
3044
  path: ctx.path
2916
3045
  });
3046
+ flagFlattenedUrls(child, ctx);
2917
3047
  if (parts.length > 0) parts.push({
2918
3048
  type: "text",
2919
3049
  text: "\n"
2920
3050
  });
2921
3051
  for (const item of child.children) parts.push({
2922
3052
  type: "text",
2923
- text: `• ${toString(item)}\n`
3053
+ text: `• ${blockToPlainText(item)}\n`
2924
3054
  });
2925
3055
  } else if (child.type === "code") {
2926
3056
  ctx.degradations.add({
@@ -2947,13 +3077,14 @@ function flattenQuote(quote, ctx) {
2947
3077
  reason: "Nested block inside blockquote flattened",
2948
3078
  path: ctx.path
2949
3079
  });
3080
+ flagFlattenedUrls(child, ctx);
2950
3081
  if (parts.length > 0) parts.push({
2951
3082
  type: "text",
2952
3083
  text: "\n"
2953
3084
  });
2954
3085
  parts.push({
2955
3086
  type: "text",
2956
- text: toString(child)
3087
+ text: blockToPlainText(child)
2957
3088
  });
2958
3089
  }
2959
3090
  return parts.length > 0 ? parts : [{
@@ -2961,6 +3092,18 @@ function flattenQuote(quote, ctx) {
2961
3092
  text: " "
2962
3093
  }];
2963
3094
  }
3095
+ /** Flattening keeps labels and alt text only; every dropped URL gets a record. */
3096
+ function flagFlattenedUrls(node, ctx) {
3097
+ const dropped = [];
3098
+ collectFlattenedUrls(node, dropped);
3099
+ for (const { nodeType, url } of dropped) ctx.degradations.add({
3100
+ nodeType,
3101
+ from: url,
3102
+ to: "label_only",
3103
+ reason: "Block flattened into quote text; URL dropped",
3104
+ path: ctx.path
3105
+ });
3106
+ }
2964
3107
  //#endregion
2965
3108
  //#region src/slack/slack-upload-error.ts
2966
3109
  /** See the brand rationale in `errors.ts`: this class is bundled into more than one entry. */
@@ -3863,11 +4006,26 @@ function emitListItem(item, ctx) {
3863
4006
  reason: "Block child of list item flattened into text",
3864
4007
  path: ctx.path
3865
4008
  });
3866
- const text = toString(child);
3867
- if (text.length > 0) phrasing.push({
3868
- type: "text",
3869
- value: text
4009
+ const dropped = [];
4010
+ collectFlattenedUrls(child, dropped);
4011
+ for (const { nodeType, url } of dropped) ctx.degradations.add({
4012
+ nodeType,
4013
+ from: url,
4014
+ to: "label_only",
4015
+ reason: "Block flattened into list item text; URL dropped",
4016
+ path: ctx.path
3870
4017
  });
4018
+ const text = blockToPlainText(child);
4019
+ if (text.length > 0) {
4020
+ if (sawBlock) phrasing.push({
4021
+ type: "text",
4022
+ value: "\n"
4023
+ });
4024
+ phrasing.push({
4025
+ type: "text",
4026
+ value: text
4027
+ });
4028
+ }
3871
4029
  sawBlock = true;
3872
4030
  }
3873
4031
  const inline = emitPhrasing(phrasing, ctx);
@@ -4438,10 +4596,30 @@ function markdownThenAscii(table, cellChars, reason) {
4438
4596
  function buildTableBlock(table, ctx) {
4439
4597
  return {
4440
4598
  type: "table",
4441
- rows: table.children.map((row, rowIndex) => row.children.map((cell) => cellToSlack(cell, ctx, rowIndex === 0))),
4599
+ rows: buildRows(table, ctx, "table"),
4442
4600
  column_settings: columnSettings(table)
4443
4601
  };
4444
4602
  }
4603
+ /**
4604
+ * Emit every cell and flag the blanks that had to be padded to satisfy Slack's
4605
+ * `minLength: 1` on `raw_text`/`raw_number` text.
4606
+ */
4607
+ function buildRows(table, ctx, mode) {
4608
+ let padded = 0;
4609
+ const rows = table.children.map((row, rowIndex) => row.children.map((cell) => {
4610
+ const emitted = mode === "data_table" ? cellForDataTable(cell, ctx, rowIndex) : cellToSlack(cell, ctx, rowIndex === 0);
4611
+ if (emitted.type === "raw_text" && emitted.text === EMPTY_CELL_TEXT) padded += 1;
4612
+ return emitted;
4613
+ }));
4614
+ if (padded > 0) ctx.degradations.add({
4615
+ nodeType: "table",
4616
+ from: `${String(padded)} empty cells`,
4617
+ to: "single-space cells",
4618
+ reason: "Slack rejects empty table cell text; padded to one space",
4619
+ path: ctx.path
4620
+ });
4621
+ return rows;
4622
+ }
4445
4623
  function buildDataTable(table, ctx) {
4446
4624
  const captionRaw = ctx.precedingHeading !== void 0 && ctx.precedingHeading.length > 0 ? ctx.precedingHeading : "Table";
4447
4625
  const caption = sliceSurrogateSafe(captionRaw, 150);
@@ -4455,16 +4633,25 @@ function buildDataTable(table, ctx) {
4455
4633
  return {
4456
4634
  type: "data_table",
4457
4635
  caption,
4458
- rows: table.children.map((row, rowIndex) => row.children.map((cell) => cellForDataTable(cell, ctx, rowIndex))),
4636
+ rows: buildRows(table, ctx, "data_table"),
4459
4637
  page_size: 5,
4460
4638
  row_header_column_index: 0
4461
4639
  };
4462
4640
  }
4641
+ /**
4642
+ * Slack requires at least one character in every `raw_text`/`raw_number` cell
4643
+ * (`minLength: 1`), so a blank markdown cell renders as a single space.
4644
+ * https://docs.slack.dev/reference/block-kit/blocks/data-table-block
4645
+ */
4646
+ const EMPTY_CELL_TEXT = " ";
4647
+ function cellText(text) {
4648
+ return text.length >= 1 ? text : EMPTY_CELL_TEXT;
4649
+ }
4463
4650
  /** Slack forbids rich_text in data_table headers; keep emission and budget counting aligned. */
4464
4651
  function cellForDataTable(cell, ctx, rowIndex) {
4465
4652
  if (rowIndex === 0) return {
4466
4653
  type: "raw_text",
4467
- text: toString(cell)
4654
+ text: cellText(toString(cell))
4468
4655
  };
4469
4656
  return cellToSlack(cell, ctx, false);
4470
4657
  }
@@ -4483,7 +4670,7 @@ function cellToSlack(cell, ctx, header) {
4483
4670
  };
4484
4671
  return {
4485
4672
  type: "raw_text",
4486
- text: plainText
4673
+ text: cellText(plainText)
4487
4674
  };
4488
4675
  }
4489
4676
  return {
@@ -4554,66 +4741,266 @@ function asciiTableBlock(table) {
4554
4741
  return preformattedRichText(toPipeMarkdown(table), void 0);
4555
4742
  }
4556
4743
  //#endregion
4744
+ //#region src/adapters/phrasing-to-markdown.ts
4745
+ /**
4746
+ * Inline markdown for content bound for a Slack `markdown` block.
4747
+ *
4748
+ * Slack renders standard markdown in that block — links, bold, italic,
4749
+ * strikethrough, inline code, and images (as link text) — so flattening inlines
4750
+ * to bare text discards fidelity the destination would have carried.
4751
+ *
4752
+ * Text nodes pass through unescaped, exactly as they did when this content was
4753
+ * flattened wholesale. This only re-adds syntax around nodes that already
4754
+ * carried that meaning in the source, so a document with no links converts
4755
+ * byte-identically to before.
4756
+ */
4757
+ function phrasingToMarkdown(nodes, definitions) {
4758
+ let out = "";
4759
+ for (const node of nodes) out += nodeToMarkdown(node, definitions);
4760
+ return out;
4761
+ }
4762
+ function nodeToMarkdown(node, definitions) {
4763
+ if (node.type === "text") return node.value;
4764
+ if (node.type === "inlineCode") return `\`${node.value}\``;
4765
+ if (node.type === "strong") return `**${phrasingToMarkdown(node.children, definitions)}**`;
4766
+ if (node.type === "emphasis") return `_${phrasingToMarkdown(node.children, definitions)}_`;
4767
+ if (node.type === "delete") return `~~${phrasingToMarkdown(node.children, definitions)}~~`;
4768
+ if (node.type === "link") return labelledLink(phrasingToMarkdown(node.children, definitions), node.url);
4769
+ if (node.type === "image") return `![${node.alt ?? ""}](${node.url})`;
4770
+ if (node.type === "linkReference") {
4771
+ const url = definitions.get(node.identifier)?.url;
4772
+ const label = phrasingToMarkdown(node.children, definitions);
4773
+ return url === void 0 ? label : labelledLink(label, url);
4774
+ }
4775
+ if (node.type === "imageReference") {
4776
+ const url = definitions.get(node.identifier)?.url;
4777
+ const alt = node.alt ?? "";
4778
+ return url === void 0 ? alt : `![${alt}](${url})`;
4779
+ }
4780
+ if (node.type === "break") return " ";
4781
+ return toString(node);
4782
+ }
4783
+ /**
4784
+ * An autolink already reads as its own URL; wrapping it as `[url](url)` would be
4785
+ * noise, and bare URLs are what this path emitted before.
4786
+ */
4787
+ function labelledLink(label, url) {
4788
+ return label === url ? url : `[${label}](${url})`;
4789
+ }
4790
+ //#endregion
4557
4791
  //#region src/adapters/task-list-adapter.ts
4558
4792
  /**
4559
4793
  * Task lists → markdown block (checkboxes); fallback unicode in rich_text_list.
4794
+ *
4795
+ * Both rungs carry inline links: Slack renders `[label](url)` in a `markdown`
4796
+ * block, and a rich_text section holds real `link` elements next to the unicode
4797
+ * checkbox. Only a block child of an item (a table, a code fence) still has to
4798
+ * be flattened, and only that flattening drops URLs.
4560
4799
  */
4561
4800
  var TaskListAdapter = class {
4562
4801
  match(node, _ctx) {
4563
4802
  return node.type === "list" && isTaskList(node);
4564
4803
  }
4565
- plan(node, _ctx) {
4804
+ plan(node, ctx) {
4566
4805
  const list = node;
4567
- const md = serializeTaskList(list);
4568
- return [{
4806
+ const md = serializeTaskList(list, ctx.definitions);
4807
+ const hoistCount = countHoistableImagesInTaskList(list, ctx);
4808
+ const candidates = [{
4569
4809
  requires: ["markdownBlock"],
4570
4810
  budget: {
4571
4811
  blocks: 1,
4572
4812
  markdownChars: md.length
4573
4813
  },
4574
- emit: () => {
4814
+ emit: (emitCtx) => {
4815
+ flagBlockChildUrlLoss(list, emitCtx);
4816
+ flagImagesRenderedAsLinks(list, emitCtx);
4575
4817
  return [{
4576
4818
  type: "markdown",
4577
4819
  text: md
4578
4820
  }];
4579
4821
  }
4580
4822
  }, {
4823
+ budget: { blocks: 1 + hoistCount },
4824
+ emit: (emitCtx) => {
4825
+ flagUnicodeCheckboxes(list, emitCtx);
4826
+ return emitUnicodeTaskBlocks(list, emitCtx, HOIST_IMAGES);
4827
+ }
4828
+ }];
4829
+ if (hoistCount > 0) candidates.push({
4581
4830
  budget: { blocks: 1 },
4582
- emit: (ctx) => {
4583
- ctx.degradations.add({
4831
+ emit: (emitCtx) => {
4832
+ flagUnicodeCheckboxes(list, emitCtx);
4833
+ emitCtx.degradations.add({
4584
4834
  nodeType: "list",
4585
- from: "markdown.task_list",
4586
- to: "rich_text_list.unicode_checkbox",
4587
- reason: "markdownBlock unavailable or budget exhausted",
4588
- path: ctx.path
4835
+ from: "rich_text+images",
4836
+ to: "rich_text",
4837
+ reason: "Budget exhausted for hoisted task list images; emitting text only",
4838
+ path: emitCtx.path
4589
4839
  });
4590
- return [unicodeTaskList(list, ctx)];
4840
+ return emitUnicodeTaskBlocks(list, emitCtx, TEXT_ONLY);
4591
4841
  }
4592
- }];
4842
+ });
4843
+ return candidates;
4593
4844
  }
4594
4845
  };
4595
- function serializeTaskList(list, indent = 0) {
4846
+ function flagUnicodeCheckboxes(list, ctx) {
4847
+ flagBlockChildUrlLoss(list, ctx);
4848
+ ctx.degradations.add({
4849
+ nodeType: "list",
4850
+ from: "markdown.task_list",
4851
+ to: "rich_text_list.unicode_checkbox",
4852
+ reason: "markdownBlock unavailable or budget exhausted",
4853
+ path: ctx.path
4854
+ });
4855
+ }
4856
+ /**
4857
+ * URLs inside a block child of an item. Both rungs flatten a table or code fence
4858
+ * to plain text, which keeps labels only, so this loss is real on either path.
4859
+ * Inline links in a paragraph are not flagged: both rungs now carry them.
4860
+ */
4861
+ function flagBlockChildUrlLoss(list, ctx) {
4862
+ for (const item of list.children) for (const child of item.children) {
4863
+ if (child.type === "list") {
4864
+ flagBlockChildUrlLoss(child, ctx);
4865
+ continue;
4866
+ }
4867
+ if (child.type === "paragraph") continue;
4868
+ const urls = [];
4869
+ collectFlattenedUrls(child, urls);
4870
+ for (const { nodeType, url } of urls) ctx.degradations.add({
4871
+ nodeType,
4872
+ from: url,
4873
+ to: "label_only",
4874
+ reason: "Block child of task list item flattened into text; URL dropped",
4875
+ path: ctx.path
4876
+ });
4877
+ }
4878
+ }
4879
+ /**
4880
+ * Slack renders `![alt](url)` in a `markdown` block as link text, so on that rung
4881
+ * the URL survives but the image does not. A fidelity note, not a dropped URL.
4882
+ * The rich_text rung hoists images into real image blocks instead.
4883
+ */
4884
+ function flagImagesRenderedAsLinks(list, ctx) {
4885
+ for (const item of list.children) for (const child of item.children) {
4886
+ if (child.type === "list") {
4887
+ flagImagesRenderedAsLinks(child, ctx);
4888
+ continue;
4889
+ }
4890
+ if (child.type !== "paragraph") continue;
4891
+ const urls = [];
4892
+ collectRenderedImageUrls(child, ctx.definitions, urls);
4893
+ for (const url of urls) ctx.degradations.add({
4894
+ nodeType: "image",
4895
+ from: url,
4896
+ to: "link",
4897
+ reason: "Slack renders images in a markdown block as link text",
4898
+ path: ctx.path
4899
+ });
4900
+ }
4901
+ }
4902
+ /**
4903
+ * Image URLs this rung will render as link text, reference images included.
4904
+ * {@link collectFlattenedUrls} cannot see those: a reference carries an identifier,
4905
+ * not a url. But {@link phrasingToMarkdown} resolves it and emits the same
4906
+ * `![alt](url)`, so it loses the same fidelity and owes the same note. An
4907
+ * unresolvable reference emits no image, and so is correctly skipped here.
4908
+ */
4909
+ function collectRenderedImageUrls(node, definitions, into) {
4910
+ if (node === null || typeof node !== "object") return;
4911
+ const record = node;
4912
+ if (record.type === "image" && typeof record.url === "string") into.push(record.url);
4913
+ if (record.type === "imageReference" && typeof record.identifier === "string") {
4914
+ const resolved = definitions.get(record.identifier)?.url;
4915
+ if (resolved !== void 0) into.push(resolved);
4916
+ }
4917
+ for (const child of record.children ?? []) collectRenderedImageUrls(child, definitions, into);
4918
+ }
4919
+ function serializeTaskList(list, definitions, indent = 0) {
4596
4920
  const pad = " ".repeat(indent);
4597
4921
  const lines = [];
4598
4922
  for (const item of list.children) {
4599
- const box = item.checked === true ? "[x]" : "[ ]";
4600
- const text = itemText(item);
4601
- lines.push(`${pad}- ${box} ${text}`);
4602
- for (const child of item.children) if (child.type === "list") lines.push(serializeTaskList(child, indent + 1));
4923
+ const text = itemMarkdown(item, definitions);
4924
+ lines.push(`${pad}${taskMarker(item.checked)}${escapeLeadingCheckbox(item.checked, text)}`);
4925
+ for (const child of item.children) if (child.type === "list") lines.push(serializeTaskList(child, definitions, indent + 1));
4603
4926
  }
4604
4927
  return lines.join("\n");
4605
4928
  }
4606
- function itemText(item) {
4929
+ /**
4930
+ * `checked` has three states, and a plain bullet is not an unchecked task. A
4931
+ * mixed list holds both, so collapsing `undefined` into `false` renders work the
4932
+ * author never wrote as unfinished. The rich_text rung already omits the prefix
4933
+ * for these items; this keeps the two rungs saying the same thing.
4934
+ */
4935
+ function taskMarker(checked) {
4936
+ if (checked === true) return "- [x] ";
4937
+ if (checked === false) return "- [ ] ";
4938
+ return "- ";
4939
+ }
4940
+ /**
4941
+ * A plain item whose text happens to start with `[x]` would be re-parsed as a
4942
+ * checked task once the marker in front of it is a bare bullet, which is the same
4943
+ * meaning change {@link taskMarker} exists to avoid. `- [x] ` with a trailing
4944
+ * space is exactly this: GFM reads it as a plain item whose literal text is
4945
+ * `[x]`. Escaping the bracket keeps it literal.
4946
+ */
4947
+ function escapeLeadingCheckbox(checked, text) {
4948
+ if (checked !== void 0 && checked !== null) return text;
4949
+ return /^\[[ xX]\]/.test(text) ? `\\${text}` : text;
4950
+ }
4951
+ /**
4952
+ * Item content as inline markdown. Slack renders links, emphasis and code in a
4953
+ * `markdown` block, so flattening those to bare text would discard fidelity the
4954
+ * destination carries natively.
4955
+ */
4956
+ function itemMarkdown(item, definitions) {
4607
4957
  const parts = [];
4608
- for (const child of item.children) if (child.type === "paragraph") parts.push(toString(child));
4609
- else if (child.type !== "list") parts.push(toString(child));
4958
+ for (const child of item.children) if (child.type === "paragraph") parts.push(phrasingToMarkdown(child.children, definitions));
4959
+ else if (child.type !== "list") parts.push(blockToPlainText(child));
4610
4960
  return parts.join(" ").trim();
4611
4961
  }
4612
- function unicodeTaskList(list, ctx) {
4613
- return {
4962
+ /** Phrasing content of a task item, for hoist counting and for emission. */
4963
+ function taskItemPhrasing(item) {
4964
+ const phrasing = [];
4965
+ for (const child of item.children) {
4966
+ let segment = [];
4967
+ if (child.type === "paragraph") segment = child.children;
4968
+ else if (child.type !== "list") {
4969
+ const text = blockToPlainText(child);
4970
+ segment = text.length > 0 ? [{
4971
+ type: "text",
4972
+ value: text
4973
+ }] : [];
4974
+ }
4975
+ if (segment.length === 0) continue;
4976
+ if (phrasing.length > 0) phrasing.push({
4977
+ type: "text",
4978
+ value: " "
4979
+ });
4980
+ phrasing.push(...segment);
4981
+ }
4982
+ return phrasing;
4983
+ }
4984
+ function countHoistableImagesInTaskList(list, ctx) {
4985
+ let count = 0;
4986
+ for (const item of list.children) {
4987
+ count += countHoistableImages(taskItemPhrasing(item), ctx);
4988
+ for (const child of item.children) if (child.type === "list") count += countHoistableImagesInTaskList(child, ctx);
4989
+ }
4990
+ return count;
4991
+ }
4992
+ /** Named at the call site, because `emitUnicodeTaskBlocks(list, ctx, true)` says nothing. */
4993
+ const HOIST_IMAGES = true;
4994
+ const TEXT_ONLY = false;
4995
+ function emitUnicodeTaskBlocks(list, ctx, hoistImages) {
4996
+ const { elements, hoistedImages } = emitUnicodeTaskTree(list, 0, ctx);
4997
+ const blocks = [{
4614
4998
  type: "rich_text",
4615
- elements: emitUnicodeTaskTree(list, 0, ctx)
4616
- };
4999
+ elements
5000
+ }];
5001
+ if (!hoistImages) return blocks;
5002
+ for (const image of hoistedImages) blocks.push(hoistedImageBlock(image, ctx));
5003
+ return blocks;
4617
5004
  }
4618
5005
  function emitUnicodeTaskTree(list, indent, ctx) {
4619
5006
  let effectiveIndent = indent;
@@ -4628,6 +5015,7 @@ function emitUnicodeTaskTree(list, indent, ctx) {
4628
5015
  effectiveIndent = 6;
4629
5016
  }
4630
5017
  const out = [];
5018
+ const hoistedImages = [];
4631
5019
  let pendingSections = [];
4632
5020
  const flush = () => {
4633
5021
  if (pendingSections.length === 0) return;
@@ -4641,18 +5029,28 @@ function emitUnicodeTaskTree(list, indent, ctx) {
4641
5029
  };
4642
5030
  for (const item of list.children) {
4643
5031
  const prefix = item.checked === true ? "☑ " : item.checked === false ? "☐ " : "";
5032
+ const inline = emitPhrasing(taskItemPhrasing(item), ctx);
5033
+ hoistedImages.push(...inline.hoistedImages);
5034
+ const elements = prefix === "" ? [...inline.elements] : [{
5035
+ type: "text",
5036
+ text: prefix
5037
+ }, ...inline.elements];
4644
5038
  pendingSections.push({
4645
5039
  type: "rich_text_section",
4646
- elements: [{
5040
+ elements: elements.length > 0 ? elements : [{
4647
5041
  type: "text",
4648
- text: `${prefix}${itemText(item)}`
5042
+ text: " "
4649
5043
  }]
4650
5044
  });
4651
5045
  const childLists = [];
4652
5046
  for (const child of item.children) if (child.type === "list") childLists.push(child);
4653
5047
  if (childLists.length === 0) continue;
4654
5048
  flush();
4655
- for (const child of childLists) out.push(...emitUnicodeTaskTree(child, effectiveIndent + 1, ctx));
5049
+ for (const child of childLists) {
5050
+ const nested = emitUnicodeTaskTree(child, effectiveIndent + 1, ctx);
5051
+ out.push(...nested.elements);
5052
+ hoistedImages.push(...nested.hoistedImages);
5053
+ }
4656
5054
  }
4657
5055
  flush();
4658
5056
  if (out.length === 0) out.push({
@@ -4667,7 +5065,10 @@ function emitUnicodeTaskTree(list, indent, ctx) {
4667
5065
  }],
4668
5066
  indent: effectiveIndent
4669
5067
  });
4670
- return out;
5068
+ return {
5069
+ elements: out,
5070
+ hoistedImages
5071
+ };
4671
5072
  }
4672
5073
  //#endregion
4673
5074
  //#region src/adapters/thematic-break-adapter.ts
@@ -5080,7 +5481,12 @@ function validateTableCells(rows, path, colsMessage, issues) {
5080
5481
  });
5081
5482
  for (const [c, cell] of row.entries()) {
5082
5483
  chars += cellCharCount(cell);
5083
- if (cell.type === "rich_text") validateRichTextBlock(cell, `${path}.rows[${String(r)}][${String(c)}]`, issues);
5484
+ const cellPath = `${path}.rows[${String(r)}][${String(c)}]`;
5485
+ if (cell.type === "rich_text") validateRichTextBlock(cell, cellPath, issues);
5486
+ else if (cell.text.length < 1) issues.push({
5487
+ path: cellPath,
5488
+ message: "table cell text is empty"
5489
+ });
5084
5490
  }
5085
5491
  }
5086
5492
  return chars;