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/adapters/block-to-plain-text.d.ts +24 -0
- package/dist/adapters/block-to-plain-text.d.ts.map +1 -0
- package/dist/adapters/blockquote-adapter.d.ts.map +1 -1
- package/dist/adapters/inline.d.ts.map +1 -1
- package/dist/adapters/list-adapter.d.ts.map +1 -1
- package/dist/adapters/phrasing-to-markdown.d.ts +15 -0
- package/dist/adapters/phrasing-to-markdown.d.ts.map +1 -0
- package/dist/adapters/table-adapter.d.ts.map +1 -1
- package/dist/adapters/task-list-adapter.d.ts +6 -1
- package/dist/adapters/task-list-adapter.d.ts.map +1 -1
- package/dist/assemble/text-fallback.d.ts.map +1 -1
- package/dist/constants.d.ts +8 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/converter/slackmark-converter.d.ts.map +1 -1
- package/dist/index.js +453 -47
- package/dist/index.js.map +1 -1
- package/dist/renderers.d.ts +1 -0
- package/dist/renderers.d.ts.map +1 -1
- package/dist/renderers.js +61 -50
- package/dist/renderers.js.map +1 -1
- package/dist/slack.d.ts +1 -0
- package/dist/slack.d.ts.map +1 -1
- package/dist/slack.js +32 -22
- package/dist/slack.js.map +1 -1
- package/dist/validate/validate-blocks.d.ts.map +1 -1
- package/package.json +1 -1
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(/[
|
|
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
|
|
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
|
-
|
|
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)
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
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: `• ${
|
|
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:
|
|
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
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
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
|
|
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
|
|
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 ``;
|
|
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 : ``;
|
|
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,
|
|
4804
|
+
plan(node, ctx) {
|
|
4566
4805
|
const list = node;
|
|
4567
|
-
const md = serializeTaskList(list);
|
|
4568
|
-
|
|
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: (
|
|
4583
|
-
|
|
4831
|
+
emit: (emitCtx) => {
|
|
4832
|
+
flagUnicodeCheckboxes(list, emitCtx);
|
|
4833
|
+
emitCtx.degradations.add({
|
|
4584
4834
|
nodeType: "list",
|
|
4585
|
-
from: "
|
|
4586
|
-
to: "
|
|
4587
|
-
reason: "
|
|
4588
|
-
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
|
|
4840
|
+
return emitUnicodeTaskBlocks(list, emitCtx, TEXT_ONLY);
|
|
4591
4841
|
}
|
|
4592
|
-
}
|
|
4842
|
+
});
|
|
4843
|
+
return candidates;
|
|
4593
4844
|
}
|
|
4594
4845
|
};
|
|
4595
|
-
function
|
|
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 `` 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
|
+
* ``, 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
|
|
4600
|
-
|
|
4601
|
-
lines.push(
|
|
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
|
-
|
|
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(
|
|
4609
|
-
else if (child.type !== "list") parts.push(
|
|
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
|
-
|
|
4613
|
-
|
|
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
|
|
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:
|
|
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)
|
|
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
|
|
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
|
-
|
|
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;
|