slackmark 0.6.1 → 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.
@@ -0,0 +1,15 @@
1
+ import type { Definition, PhrasingContent } from "mdast";
2
+ /**
3
+ * Inline markdown for content bound for a Slack `markdown` block.
4
+ *
5
+ * Slack renders standard markdown in that block — links, bold, italic,
6
+ * strikethrough, inline code, and images (as link text) — so flattening inlines
7
+ * to bare text discards fidelity the destination would have carried.
8
+ *
9
+ * Text nodes pass through unescaped, exactly as they did when this content was
10
+ * flattened wholesale. This only re-adds syntax around nodes that already
11
+ * carried that meaning in the source, so a document with no links converts
12
+ * byte-identically to before.
13
+ */
14
+ export declare function phrasingToMarkdown(nodes: ReadonlyArray<PhrasingContent>, definitions: ReadonlyMap<string, Definition>): string;
15
+ //# sourceMappingURL=phrasing-to-markdown.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"phrasing-to-markdown.d.ts","sourceRoot":"","sources":["../../src/adapters/phrasing-to-markdown.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,OAAO,CAAC;AAGzD;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,aAAa,CAAC,eAAe,CAAC,EACrC,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,UAAU,CAAC,GAC3C,MAAM,CAMR"}
@@ -3,9 +3,14 @@ import type { ConversionContext } from "../budget/conversion-context.js";
3
3
  import type { Candidate, NodeAdapter } from "./node-adapter.js";
4
4
  /**
5
5
  * Task lists → markdown block (checkboxes); fallback unicode in rich_text_list.
6
+ *
7
+ * Both rungs carry inline links: Slack renders `[label](url)` in a `markdown`
8
+ * block, and a rich_text section holds real `link` elements next to the unicode
9
+ * checkbox. Only a block child of an item (a table, a code fence) still has to
10
+ * be flattened, and only that flattening drops URLs.
6
11
  */
7
12
  export declare class TaskListAdapter implements NodeAdapter {
8
13
  match(node: RootContent, _ctx: ConversionContext): boolean;
9
- plan(node: RootContent, _ctx: ConversionContext): ReadonlyArray<Candidate>;
14
+ plan(node: RootContent, ctx: ConversionContext): ReadonlyArray<Candidate>;
10
15
  }
11
16
  //# sourceMappingURL=task-list-adapter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"task-list-adapter.d.ts","sourceRoot":"","sources":["../../src/adapters/task-list-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAkB,WAAW,EAAE,MAAM,OAAO,CAAC;AAUzD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iCAAiC,CAAC;AAKzE,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEhE;;GAEG;AACH,qBAAa,eAAgB,YAAW,WAAW;IACjD,KAAK,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAEzD;IAED,IAAI,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,iBAAiB,GAAG,aAAa,CAAC,SAAS,CAAC,CA4BzE;CACF"}
1
+ {"version":3,"file":"task-list-adapter.d.ts","sourceRoot":"","sources":["../../src/adapters/task-list-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAA+C,WAAW,EAAE,MAAM,OAAO,CAAC;AAStF,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iCAAiC,CAAC;AAezE,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAGhE;;;;;;;GAOG;AACH,qBAAa,eAAgB,YAAW,WAAW;IACjD,KAAK,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAEzD;IAED,IAAI,CAAC,IAAI,EAAE,WAAW,EAAE,GAAG,EAAE,iBAAiB,GAAG,aAAa,CAAC,SAAS,CAAC,CAwCxE;CACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"text-fallback.d.ts","sourceRoot":"","sources":["../../src/assemble/text-fallback.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAmC,MAAM,oBAAoB,CAAC;AACjF,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAErD;;GAEG;AACH,qBAAa,8BAA+B,YAAW,iBAAiB;IACtE,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CA2B/B;IAED,gBAAgB,CAAC,MAAM,EAAE,aAAa,CAAC,KAAK,CAAC,GAAG,MAAM,CAsDrD;CACF"}
1
+ {"version":3,"file":"text-fallback.d.ts","sourceRoot":"","sources":["../../src/assemble/text-fallback.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAmC,MAAM,oBAAoB,CAAC;AACjF,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAErD;;GAEG;AACH,qBAAa,8BAA+B,YAAW,iBAAiB;IACtE,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAkC/B;IAED,gBAAgB,CAAC,MAAM,EAAE,aAAa,CAAC,KAAK,CAAC,GAAG,MAAM,CAsDrD;CACF"}
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 = [];
@@ -3013,7 +3043,7 @@ function flattenQuote(quote, ctx) {
3013
3043
  reason: "Nested list inside blockquote flattened",
3014
3044
  path: ctx.path
3015
3045
  });
3016
- flagFlattenedUrls$1(child, ctx);
3046
+ flagFlattenedUrls(child, ctx);
3017
3047
  if (parts.length > 0) parts.push({
3018
3048
  type: "text",
3019
3049
  text: "\n"
@@ -3047,7 +3077,7 @@ function flattenQuote(quote, ctx) {
3047
3077
  reason: "Nested block inside blockquote flattened",
3048
3078
  path: ctx.path
3049
3079
  });
3050
- flagFlattenedUrls$1(child, ctx);
3080
+ flagFlattenedUrls(child, ctx);
3051
3081
  if (parts.length > 0) parts.push({
3052
3082
  type: "text",
3053
3083
  text: "\n"
@@ -3063,7 +3093,7 @@ function flattenQuote(quote, ctx) {
3063
3093
  }];
3064
3094
  }
3065
3095
  /** Flattening keeps labels and alt text only; every dropped URL gets a record. */
3066
- function flagFlattenedUrls$1(node, ctx) {
3096
+ function flagFlattenedUrls(node, ctx) {
3067
3097
  const dropped = [];
3068
3098
  collectFlattenedUrls(node, dropped);
3069
3099
  for (const { nodeType, url } of dropped) ctx.degradations.add({
@@ -4711,83 +4741,266 @@ function asciiTableBlock(table) {
4711
4741
  return preformattedRichText(toPipeMarkdown(table), void 0);
4712
4742
  }
4713
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
4714
4791
  //#region src/adapters/task-list-adapter.ts
4715
4792
  /**
4716
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.
4717
4799
  */
4718
4800
  var TaskListAdapter = class {
4719
4801
  match(node, _ctx) {
4720
4802
  return node.type === "list" && isTaskList(node);
4721
4803
  }
4722
- plan(node, _ctx) {
4804
+ plan(node, ctx) {
4723
4805
  const list = node;
4724
- const md = serializeTaskList(list);
4725
- return [{
4806
+ const md = serializeTaskList(list, ctx.definitions);
4807
+ const hoistCount = countHoistableImagesInTaskList(list, ctx);
4808
+ const candidates = [{
4726
4809
  requires: ["markdownBlock"],
4727
4810
  budget: {
4728
4811
  blocks: 1,
4729
4812
  markdownChars: md.length
4730
4813
  },
4731
- emit: (ctx) => {
4732
- flagFlattenedUrls(list, ctx);
4814
+ emit: (emitCtx) => {
4815
+ flagBlockChildUrlLoss(list, emitCtx);
4816
+ flagImagesRenderedAsLinks(list, emitCtx);
4733
4817
  return [{
4734
4818
  type: "markdown",
4735
4819
  text: md
4736
4820
  }];
4737
4821
  }
4738
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({
4739
4830
  budget: { blocks: 1 },
4740
- emit: (ctx) => {
4741
- flagFlattenedUrls(list, ctx);
4742
- ctx.degradations.add({
4831
+ emit: (emitCtx) => {
4832
+ flagUnicodeCheckboxes(list, emitCtx);
4833
+ emitCtx.degradations.add({
4743
4834
  nodeType: "list",
4744
- from: "markdown.task_list",
4745
- to: "rich_text_list.unicode_checkbox",
4746
- reason: "markdownBlock unavailable or budget exhausted",
4747
- 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
4748
4839
  });
4749
- return [unicodeTaskList(list, ctx)];
4840
+ return emitUnicodeTaskBlocks(list, emitCtx, TEXT_ONLY);
4750
4841
  }
4751
- }];
4842
+ });
4843
+ return candidates;
4752
4844
  }
4753
4845
  };
4754
- 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) {
4755
4920
  const pad = " ".repeat(indent);
4756
4921
  const lines = [];
4757
4922
  for (const item of list.children) {
4758
- const box = item.checked === true ? "[x]" : "[ ]";
4759
- const text = itemText(item);
4760
- lines.push(`${pad}- ${box} ${text}`);
4761
- 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));
4762
4926
  }
4763
4927
  return lines.join("\n");
4764
4928
  }
4765
4929
  /**
4766
- * Both task-list rungs render item content as plain text ({@link itemText}),
4767
- * so a `[label](url)` or `![alt](url)` inside an item keeps only its label.
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.
4768
4934
  */
4769
- function flagFlattenedUrls(list, ctx) {
4770
- const dropped = [];
4771
- collectFlattenedUrls(list, dropped);
4772
- for (const { nodeType, url } of dropped) ctx.degradations.add({
4773
- nodeType,
4774
- from: url,
4775
- to: "label_only",
4776
- reason: "Task list item rendered as plain text; URL dropped",
4777
- path: ctx.path
4778
- });
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;
4779
4950
  }
4780
- function itemText(item) {
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) {
4781
4957
  const parts = [];
4782
- for (const child of item.children) if (child.type === "paragraph") parts.push(toString(child));
4783
- 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));
4784
4960
  return parts.join(" ").trim();
4785
4961
  }
4786
- function unicodeTaskList(list, ctx) {
4787
- 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 = [{
4788
4998
  type: "rich_text",
4789
- elements: emitUnicodeTaskTree(list, 0, ctx)
4790
- };
4999
+ elements
5000
+ }];
5001
+ if (!hoistImages) return blocks;
5002
+ for (const image of hoistedImages) blocks.push(hoistedImageBlock(image, ctx));
5003
+ return blocks;
4791
5004
  }
4792
5005
  function emitUnicodeTaskTree(list, indent, ctx) {
4793
5006
  let effectiveIndent = indent;
@@ -4802,6 +5015,7 @@ function emitUnicodeTaskTree(list, indent, ctx) {
4802
5015
  effectiveIndent = 6;
4803
5016
  }
4804
5017
  const out = [];
5018
+ const hoistedImages = [];
4805
5019
  let pendingSections = [];
4806
5020
  const flush = () => {
4807
5021
  if (pendingSections.length === 0) return;
@@ -4815,18 +5029,28 @@ function emitUnicodeTaskTree(list, indent, ctx) {
4815
5029
  };
4816
5030
  for (const item of list.children) {
4817
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];
4818
5038
  pendingSections.push({
4819
5039
  type: "rich_text_section",
4820
- elements: [{
5040
+ elements: elements.length > 0 ? elements : [{
4821
5041
  type: "text",
4822
- text: `${prefix}${itemText(item)}`
5042
+ text: " "
4823
5043
  }]
4824
5044
  });
4825
5045
  const childLists = [];
4826
5046
  for (const child of item.children) if (child.type === "list") childLists.push(child);
4827
5047
  if (childLists.length === 0) continue;
4828
5048
  flush();
4829
- 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
+ }
4830
5054
  }
4831
5055
  flush();
4832
5056
  if (out.length === 0) out.push({
@@ -4841,7 +5065,10 @@ function emitUnicodeTaskTree(list, indent, ctx) {
4841
5065
  }],
4842
5066
  indent: effectiveIndent
4843
5067
  });
4844
- return out;
5068
+ return {
5069
+ elements: out,
5070
+ hoistedImages
5071
+ };
4845
5072
  }
4846
5073
  //#endregion
4847
5074
  //#region src/adapters/thematic-break-adapter.ts