slackmark 0.3.0 → 0.4.0

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
@@ -1,3 +1,12 @@
1
+ // src/errors.ts
2
+ var ConfigurationError = class extends Error {
3
+ name;
4
+ constructor(message, options) {
5
+ super(message, options?.cause !== void 0 ? { cause: options.cause } : void 0);
6
+ this.name = "ConfigurationError";
7
+ }
8
+ };
9
+
1
10
  // src/capabilities/capabilities.ts
2
11
  var CapabilityPresetName = {
3
12
  Latest: "latest",
@@ -59,12 +68,13 @@ function resolveCapabilities(input, overrides) {
59
68
  if (input === void 0) {
60
69
  base = CAPABILITY_PRESETS.latest;
61
70
  } else if (typeof input === "string") {
62
- const preset = CAPABILITY_PRESETS[input];
71
+ const preset = isPresetName(input) ? CAPABILITY_PRESETS[input] : void 0;
63
72
  if (preset === void 0) {
64
- base = CAPABILITY_PRESETS.latest;
65
- } else {
66
- base = preset;
73
+ throw new ConfigurationError(
74
+ `Unknown capability preset ${JSON.stringify(input)}; use one of: ${Object.keys(CAPABILITY_PRESETS).join(", ")}.`
75
+ );
67
76
  }
77
+ base = preset;
68
78
  } else if (isFullFlags(input)) {
69
79
  base = input;
70
80
  } else {
@@ -75,6 +85,9 @@ function resolveCapabilities(input, overrides) {
75
85
  }
76
86
  return Object.freeze({ ...base, ...overrides });
77
87
  }
88
+ function isPresetName(value) {
89
+ return Object.hasOwn(CAPABILITY_PRESETS, value);
90
+ }
78
91
  function isFullFlags(value) {
79
92
  return typeof value.richText === "boolean" && typeof value.markdownBlock === "boolean" && typeof value.tableBlock === "boolean" && typeof value.preformattedLanguage === "boolean" && typeof value.headerLevel === "boolean" && typeof value.dataTable === "boolean" && typeof value.dataVisualization === "boolean" && typeof value.containerBlock === "boolean";
80
93
  }
@@ -298,6 +311,29 @@ function assertNever(value, message) {
298
311
  throw new Error(`${detail}: ${JSON.stringify(value)}`);
299
312
  }
300
313
 
314
+ // src/utf16.ts
315
+ var HIGH_SURROGATE_MIN = 55296;
316
+ var HIGH_SURROGATE_MAX = 56319;
317
+ var LOW_SURROGATE_MIN = 56320;
318
+ var LOW_SURROGATE_MAX = 57343;
319
+ function surrogateSafeEnd(text, end) {
320
+ if (end <= 0) {
321
+ return 0;
322
+ }
323
+ if (end >= text.length) {
324
+ return text.length;
325
+ }
326
+ const before = text.charCodeAt(end - 1);
327
+ const after = text.charCodeAt(end);
328
+ if (before >= HIGH_SURROGATE_MIN && before <= HIGH_SURROGATE_MAX && after >= LOW_SURROGATE_MIN && after <= LOW_SURROGATE_MAX) {
329
+ return end - 1;
330
+ }
331
+ return end;
332
+ }
333
+ function sliceSurrogateSafe(text, max) {
334
+ return text.slice(0, surrogateSafeEnd(text, max));
335
+ }
336
+
301
337
  // src/types.ts
302
338
  var OverflowMode = {
303
339
  Degrade: "degrade",
@@ -398,7 +434,7 @@ var MessageAssembler = class {
398
434
  reason: "Notification text truncated to 40k safety limit",
399
435
  path: ctx.path
400
436
  });
401
- return { blocks, text: raw.slice(0, MESSAGE_TEXT_SAFETY_MAX_CHARS) };
437
+ return { blocks, text: sliceSurrogateSafe(raw, MESSAGE_TEXT_SAFETY_MAX_CHARS) };
402
438
  }
403
439
  };
404
440
  function buildFootnoteBlocks(entries, degradations) {
@@ -417,7 +453,7 @@ function buildFootnoteBlocks(entries, degradations) {
417
453
  reason: "Footnote context text truncated to 3000 chars",
418
454
  path: "footnote"
419
455
  });
420
- elements.push({ type: "mrkdwn", text: note.slice(0, SECTION_TEXT_MAX_CHARS) });
456
+ elements.push({ type: "mrkdwn", text: sliceSurrogateSafe(note, SECTION_TEXT_MAX_CHARS) });
421
457
  } else {
422
458
  elements.push({ type: "mrkdwn", text: note });
423
459
  }
@@ -622,7 +658,7 @@ function inlinesToPlain(elements) {
622
658
  parts.push(`@${el.range}`);
623
659
  break;
624
660
  case "date":
625
- parts.push(el.fallback ?? el.format);
661
+ parts.push(el.fallback ?? formatDateTokensUtc(el.timestamp, el.format));
626
662
  break;
627
663
  default:
628
664
  break;
@@ -630,6 +666,101 @@ function inlinesToPlain(elements) {
630
666
  }
631
667
  return parts.join("");
632
668
  }
669
+ var DATE_TOKEN_RE = /\{([a-z_]+)\}/g;
670
+ var MONTHS = [
671
+ "January",
672
+ "February",
673
+ "March",
674
+ "April",
675
+ "May",
676
+ "June",
677
+ "July",
678
+ "August",
679
+ "September",
680
+ "October",
681
+ "November",
682
+ "December"
683
+ ];
684
+ var WEEKDAYS = [
685
+ "Sunday",
686
+ "Monday",
687
+ "Tuesday",
688
+ "Wednesday",
689
+ "Thursday",
690
+ "Friday",
691
+ "Saturday"
692
+ ];
693
+ function pad2(value) {
694
+ return String(value).padStart(2, "0");
695
+ }
696
+ function ordinal(value) {
697
+ const remainder = value % 100;
698
+ if (remainder >= 11 && remainder <= 13) {
699
+ return `${String(value)}th`;
700
+ }
701
+ switch (value % 10) {
702
+ case 1:
703
+ return `${String(value)}st`;
704
+ case 2:
705
+ return `${String(value)}nd`;
706
+ case 3:
707
+ return `${String(value)}rd`;
708
+ default:
709
+ return `${String(value)}th`;
710
+ }
711
+ }
712
+ function dateToken(token, date) {
713
+ const day = date.getUTCDate();
714
+ const month = MONTHS[date.getUTCMonth()] ?? "";
715
+ const shortMonth = month.slice(0, 3);
716
+ const weekday = WEEKDAYS[date.getUTCDay()] ?? "";
717
+ const year = date.getUTCFullYear();
718
+ switch (token) {
719
+ case "date_num":
720
+ return `${String(year)}-${pad2(date.getUTCMonth() + 1)}-${pad2(day)}`;
721
+ // Slack renders {date_slash} month-first: 02/18/2014 (docs example for
722
+ // timestamp 1392734382).
723
+ case "date_slash":
724
+ return `${pad2(date.getUTCMonth() + 1)}/${pad2(day)}/${String(year)}`;
725
+ case "date_long":
726
+ case "date_long_pretty":
727
+ return `${weekday}, ${month} ${ordinal(day)}, ${String(year)}`;
728
+ case "date_long_full":
729
+ return `${month} ${String(day)}, ${String(year)}`;
730
+ // {date} renders "February 18th, 2014" (ordinal + year), not "February 18".
731
+ case "date":
732
+ case "date_pretty":
733
+ return `${month} ${ordinal(day)}, ${String(year)}`;
734
+ case "date_short":
735
+ case "date_short_pretty":
736
+ return `${shortMonth} ${String(day)}, ${String(year)}`;
737
+ case "time":
738
+ return `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}`;
739
+ case "time_secs":
740
+ return `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}`;
741
+ default:
742
+ return void 0;
743
+ }
744
+ }
745
+ function formatDateTokensUtc(timestamp, format) {
746
+ const date = new Date(timestamp * 1e3);
747
+ if (!Number.isFinite(timestamp) || Number.isNaN(date.getTime())) {
748
+ return String(timestamp);
749
+ }
750
+ let unsupported = false;
751
+ const formatted = format.replace(DATE_TOKEN_RE, (_match, token) => {
752
+ const replacement = dateToken(token, date);
753
+ if (replacement === void 0) {
754
+ unsupported = true;
755
+ return "";
756
+ }
757
+ return replacement;
758
+ });
759
+ if (format.length === 0 || unsupported) {
760
+ return date.toISOString().slice(0, 10);
761
+ }
762
+ return formatted;
763
+ }
633
764
  function tableToPlain(rows) {
634
765
  const lines = [];
635
766
  for (const row of rows) {
@@ -869,15 +1000,6 @@ function parseNumberList(inner) {
869
1000
  // src/converter/slackmark-converter.ts
870
1001
  import { toString } from "mdast-util-to-string";
871
1002
 
872
- // src/errors.ts
873
- var ConfigurationError = class extends Error {
874
- name;
875
- constructor(message, options) {
876
- super(message, options?.cause !== void 0 ? { cause: options.cause } : void 0);
877
- this.name = "ConfigurationError";
878
- }
879
- };
880
-
881
1003
  // src/converter/resolve-config.ts
882
1004
  function resolveConfig(defaults, overrides) {
883
1005
  const merged = {
@@ -967,7 +1089,7 @@ var SlackMarkConverter = class {
967
1089
  enableMath: config.enableMath,
968
1090
  enableFrontmatter: config.enableFrontmatter
969
1091
  });
970
- const { definitions, footnotes, footnoteOrder } = collectRefs(tree);
1092
+ const { definitions, footnotes, footnoteOrder } = collectRefs(tree, degradations);
971
1093
  let precedingHeading;
972
1094
  const emitted = [];
973
1095
  let index = 0;
@@ -992,11 +1114,13 @@ var SlackMarkConverter = class {
992
1114
  emitted.push(...blocks);
993
1115
  index += 1;
994
1116
  }
1117
+ const footnoteNumbers = /* @__PURE__ */ new Map();
1118
+ footnoteOrder.forEach((id, i) => footnoteNumbers.set(id, i + 1));
995
1119
  const footnoteEntries = footnoteOrder.map((id, i) => {
996
1120
  const def = footnotes.get(id);
997
1121
  return {
998
1122
  n: i + 1,
999
- text: def !== void 0 ? toString(def).trim() : id
1123
+ text: def !== void 0 ? footnoteBodyText(def, footnoteNumbers).trim() : id
1000
1124
  };
1001
1125
  });
1002
1126
  const footnoteBlocks = buildFootnoteBlocks(footnoteEntries, degradations);
@@ -1105,7 +1229,7 @@ function safeNotificationText(plainText, markdown, degradations) {
1105
1229
  reason: "Notification text truncated to 40k safety limit",
1106
1230
  path: "root"
1107
1231
  });
1108
- return raw.slice(0, MESSAGE_TEXT_SAFETY_MAX_CHARS);
1232
+ return sliceSurrogateSafe(raw, MESSAGE_TEXT_SAFETY_MAX_CHARS);
1109
1233
  }
1110
1234
  function clampSectionFallback(text, degradations) {
1111
1235
  if (text.length <= SECTION_TEXT_MAX_CHARS) {
@@ -1118,17 +1242,38 @@ function clampSectionFallback(text, degradations) {
1118
1242
  reason: "Section text truncated to 3000 chars",
1119
1243
  path: "root"
1120
1244
  });
1121
- return text.slice(0, SECTION_TEXT_MAX_CHARS);
1245
+ return sliceSurrogateSafe(text, SECTION_TEXT_MAX_CHARS);
1122
1246
  }
1123
- function collectRefs(tree) {
1247
+ function collectRefs(tree, degradations) {
1124
1248
  const definitions = /* @__PURE__ */ new Map();
1125
1249
  const footnotes = /* @__PURE__ */ new Map();
1126
1250
  for (const node of tree.children) {
1127
1251
  if (node.type === "definition") {
1128
- definitions.set(node.identifier.toLowerCase(), node);
1252
+ const key = node.identifier.toLowerCase();
1253
+ if (definitions.has(key)) {
1254
+ degradations.add({
1255
+ nodeType: "definition",
1256
+ from: node.identifier,
1257
+ to: "ignored",
1258
+ reason: "Duplicate link reference definition; first definition wins",
1259
+ path: "root"
1260
+ });
1261
+ } else {
1262
+ definitions.set(key, node);
1263
+ }
1129
1264
  }
1130
1265
  if (node.type === "footnoteDefinition") {
1131
- footnotes.set(node.identifier, node);
1266
+ if (footnotes.has(node.identifier)) {
1267
+ degradations.add({
1268
+ nodeType: "footnoteDefinition",
1269
+ from: node.identifier,
1270
+ to: "ignored",
1271
+ reason: "Duplicate footnote definition; first definition wins",
1272
+ path: "root"
1273
+ });
1274
+ } else {
1275
+ footnotes.set(node.identifier, node);
1276
+ }
1132
1277
  }
1133
1278
  }
1134
1279
  const footnoteOrder = [];
@@ -1139,8 +1284,46 @@ function collectRefs(tree) {
1139
1284
  footnoteOrder.push(id);
1140
1285
  }
1141
1286
  });
1287
+ for (let i = 0; i < footnoteOrder.length; i += 1) {
1288
+ const def = footnotes.get(footnoteOrder[i] ?? "");
1289
+ if (def === void 0) {
1290
+ continue;
1291
+ }
1292
+ for (const child of def.children) {
1293
+ walkFootnoteReferences(child, (id) => {
1294
+ if (!seen.has(id)) {
1295
+ seen.add(id);
1296
+ footnoteOrder.push(id);
1297
+ }
1298
+ });
1299
+ }
1300
+ }
1142
1301
  return { definitions, footnotes, footnoteOrder };
1143
1302
  }
1303
+ function footnoteBodyText(node, numbers) {
1304
+ if (node === null || typeof node !== "object") {
1305
+ return "";
1306
+ }
1307
+ const record = node;
1308
+ if (record.type === "footnoteReference" && typeof record.identifier === "string") {
1309
+ const n = numbers.get(record.identifier);
1310
+ return n !== void 0 ? `[${String(n)}]` : `[^${record.identifier}]`;
1311
+ }
1312
+ if (typeof record.value === "string") {
1313
+ return record.value;
1314
+ }
1315
+ if (typeof record.alt === "string") {
1316
+ return record.alt;
1317
+ }
1318
+ if (record.children !== void 0) {
1319
+ const parts = [];
1320
+ for (const child of record.children) {
1321
+ parts.push(footnoteBodyText(child, numbers));
1322
+ }
1323
+ return parts.join("");
1324
+ }
1325
+ return "";
1326
+ }
1144
1327
  function walkFootnoteReferences(node, onRef) {
1145
1328
  if (node === null || typeof node !== "object") {
1146
1329
  return;
@@ -1164,8 +1347,11 @@ function chunkMarkdown(text, max) {
1164
1347
  return [text.length > 0 ? text : " "];
1165
1348
  }
1166
1349
  const out = [];
1167
- for (let i = 0; i < text.length; i += max) {
1168
- out.push(text.slice(i, i + max));
1350
+ let i = 0;
1351
+ while (i < text.length) {
1352
+ const end = surrogateSafeEnd(text, i + max);
1353
+ out.push(text.slice(i, end));
1354
+ i = end;
1169
1355
  }
1170
1356
  return out;
1171
1357
  }
@@ -1175,45 +1361,7 @@ import { toString as toString3 } from "mdast-util-to-string";
1175
1361
 
1176
1362
  // src/adapters/inline.ts
1177
1363
  import { toString as toString2 } from "mdast-util-to-string";
1178
- var EMOJI_SHORTCODE_RE = /:([a-z0-9_+-]{1,64}):/g;
1179
- var KNOWN_SLACK_EMOJI = /* @__PURE__ */ new Set([
1180
- "smile",
1181
- "grinning",
1182
- "wink",
1183
- "blush",
1184
- "wave",
1185
- "thumbsup",
1186
- "thumbsdown",
1187
- "tada",
1188
- "rocket",
1189
- "fire",
1190
- "heart",
1191
- "eyes",
1192
- "thinking_face",
1193
- "sweat_smile",
1194
- "joy",
1195
- "sob",
1196
- "clap",
1197
- "100",
1198
- "white_check_mark",
1199
- "x",
1200
- "warning",
1201
- "bulb",
1202
- "memo",
1203
- "mag",
1204
- "link",
1205
- "gear",
1206
- "bug",
1207
- "sparkles",
1208
- "+1",
1209
- "-1",
1210
- "ok_hand",
1211
- "pray",
1212
- "star",
1213
- "zap",
1214
- "check",
1215
- "heavy_check_mark"
1216
- ]);
1364
+ var EMOJI_SHORTCODE_RE = /(?<![A-Za-z0-9]):([a-z0-9_+-]{1,64}):(?![A-Za-z0-9])/g;
1217
1365
  var HTTP_URL_RE = /^(https?:|mailto:)/i;
1218
1366
  var IMAGE_EXT_RE = /\.(png|jpe?g|gif)(?:\?|#|$)/i;
1219
1367
  function emitPhrasing(nodes, ctx, baseStyle = {}) {
@@ -1221,26 +1369,24 @@ function emitPhrasing(nodes, ctx, baseStyle = {}) {
1221
1369
  const hoistedImages = [];
1222
1370
  const work = [...nodes];
1223
1371
  let pendingAnchor;
1372
+ const htmlStyles = [];
1373
+ const activeStyle = () => htmlStyles[htmlStyles.length - 1] ?? baseStyle;
1224
1374
  const flushPendingAnchor = () => {
1225
1375
  if (pendingAnchor === void 0) {
1226
1376
  return;
1227
1377
  }
1228
- const label = pendingAnchor.textParts.join("");
1229
- elements.push({
1230
- type: "link",
1231
- url: pendingAnchor.href,
1232
- text: label.length > 0 ? label : pendingAnchor.href,
1233
- style: styleOrUndefined(pendingAnchor.style)
1234
- });
1378
+ elements.push(anchorInline(pendingAnchor, ctx));
1235
1379
  pendingAnchor = void 0;
1236
1380
  };
1237
1381
  for (let i = 0; i < work.length; i += 1) {
1238
1382
  const node = work[i];
1239
1383
  if (pendingAnchor === void 0) {
1240
- const reassembled = reassembleSlackLabeledLink(work, i, baseStyle);
1384
+ const reassembled = reassembleSlackLabeledLink(work, i, activeStyle());
1241
1385
  if (reassembled !== void 0) {
1242
1386
  if (reassembled.leadingText.value.length > 0) {
1243
- elements.push(...emitTextWithMentionsAndEmoji(reassembled.leadingText, baseStyle, ctx));
1387
+ elements.push(
1388
+ ...emitTextWithMentionsAndEmoji(reassembled.leadingText, activeStyle(), ctx)
1389
+ );
1244
1390
  }
1245
1391
  elements.push(reassembled.link);
1246
1392
  if (reassembled.trailingText.value.length > 0) {
@@ -1270,7 +1416,7 @@ function emitPhrasing(nodes, ctx, baseStyle = {}) {
1270
1416
  }
1271
1417
  if (node.type === "html") {
1272
1418
  const trimmed = node.value.trim();
1273
- const entityInlines = slackEntityRichTextInlines(trimmed, baseStyle);
1419
+ const entityInlines = slackEntityRichTextInlines(trimmed, activeStyle());
1274
1420
  if (entityInlines !== void 0) {
1275
1421
  elements.push(...entityInlines);
1276
1422
  continue;
@@ -1279,28 +1425,45 @@ function emitPhrasing(nodes, ctx, baseStyle = {}) {
1279
1425
  if (openOnly !== null) {
1280
1426
  const hrefMatch = /href\s*=\s*["']([^"']{0,3000})["']/i.exec(openOnly[1] ?? "");
1281
1427
  const href = hrefMatch?.[1];
1282
- if (href !== void 0 && isValidLinkUrl(href)) {
1283
- pendingAnchor = { href, style: baseStyle, textParts: [] };
1428
+ if (href !== void 0) {
1429
+ pendingAnchor = {
1430
+ href,
1431
+ style: activeStyle(),
1432
+ textParts: [],
1433
+ valid: isValidLinkUrl(href)
1434
+ };
1284
1435
  continue;
1285
1436
  }
1286
1437
  }
1287
- elements.push(...emitInlineHtml(node.value, baseStyle, ctx));
1438
+ const openTag = HTML_STYLE_OPEN_RE.exec(trimmed);
1439
+ if (openTag !== null) {
1440
+ htmlStyles.push(htmlTagStyle(activeStyle(), openTag[1] ?? ""));
1441
+ continue;
1442
+ }
1443
+ if (HTML_STYLE_CLOSE_RE.test(trimmed)) {
1444
+ htmlStyles.pop();
1445
+ continue;
1446
+ }
1447
+ elements.push(...emitInlineHtml(node.value, activeStyle(), ctx));
1288
1448
  continue;
1289
1449
  }
1290
1450
  switch (node.type) {
1291
1451
  case "text": {
1292
- elements.push(...emitTextWithMentionsAndEmoji(node, baseStyle, ctx));
1452
+ elements.push(...emitTextWithMentionsAndEmoji(node, activeStyle(), ctx));
1293
1453
  break;
1294
1454
  }
1295
1455
  case "strong": {
1296
- const inner = emitPhrasing(node.children, ctx, { ...baseStyle, bold: true });
1456
+ const inner = emitPhrasing(node.children, ctx, {
1457
+ ...activeStyle(),
1458
+ bold: true
1459
+ });
1297
1460
  elements.push(...inner.elements);
1298
1461
  hoistedImages.push(...inner.hoistedImages);
1299
1462
  break;
1300
1463
  }
1301
1464
  case "emphasis": {
1302
1465
  const inner = emitPhrasing(node.children, ctx, {
1303
- ...baseStyle,
1466
+ ...activeStyle(),
1304
1467
  italic: true
1305
1468
  });
1306
1469
  elements.push(...inner.elements);
@@ -1308,7 +1471,7 @@ function emitPhrasing(nodes, ctx, baseStyle = {}) {
1308
1471
  break;
1309
1472
  }
1310
1473
  case "delete": {
1311
- const inner = emitPhrasing(node.children, ctx, { ...baseStyle, strike: true });
1474
+ const inner = emitPhrasing(node.children, ctx, { ...activeStyle(), strike: true });
1312
1475
  elements.push(...inner.elements);
1313
1476
  hoistedImages.push(...inner.hoistedImages);
1314
1477
  break;
@@ -1317,21 +1480,21 @@ function emitPhrasing(nodes, ctx, baseStyle = {}) {
1317
1480
  const el = {
1318
1481
  type: "text",
1319
1482
  text: node.value,
1320
- style: styleOrUndefined({ ...baseStyle, code: true })
1483
+ style: styleOrUndefined({ ...activeStyle(), code: true })
1321
1484
  };
1322
1485
  elements.push(el);
1323
1486
  break;
1324
1487
  }
1325
1488
  case "break": {
1326
- elements.push({ type: "text", text: "\n", style: styleOrUndefined(baseStyle) });
1489
+ elements.push({ type: "text", text: "\n", style: styleOrUndefined(activeStyle()) });
1327
1490
  break;
1328
1491
  }
1329
1492
  case "link": {
1330
- elements.push(...emitLink(node, ctx, baseStyle));
1493
+ elements.push(...emitLink(node, ctx, activeStyle()));
1331
1494
  break;
1332
1495
  }
1333
1496
  case "linkReference": {
1334
- elements.push(...emitLinkReference(node, ctx, baseStyle));
1497
+ elements.push(...emitLinkReference(node, ctx, activeStyle()));
1335
1498
  break;
1336
1499
  }
1337
1500
  case "image": {
@@ -1346,22 +1509,33 @@ function emitPhrasing(nodes, ctx, baseStyle = {}) {
1346
1509
  type: "link",
1347
1510
  url,
1348
1511
  text: node.alt && node.alt.length > 0 ? node.alt : url,
1349
- style: styleOrUndefined(baseStyle)
1512
+ style: styleOrUndefined(activeStyle())
1350
1513
  });
1351
1514
  } else {
1515
+ const linkable = isValidLinkUrl(url);
1352
1516
  ctx.degradations.add({
1353
1517
  nodeType: "image",
1354
1518
  from: "image",
1355
- to: "link",
1519
+ to: linkable ? "link" : "text",
1356
1520
  reason: "Image URL is not a public http(s) png/jpg/gif \u22643000 chars",
1357
1521
  path: ctx.path
1358
1522
  });
1359
- elements.push({
1360
- type: "link",
1361
- url: url.length > 0 ? url : "#",
1362
- text: node.alt && node.alt.length > 0 ? node.alt : url,
1363
- style: styleOrUndefined(baseStyle)
1364
- });
1523
+ const label = node.alt && node.alt.length > 0 ? node.alt : url.length > 0 ? url : "image";
1524
+ if (linkable) {
1525
+ elements.push({
1526
+ type: "link",
1527
+ url,
1528
+ text: label,
1529
+ style: styleOrUndefined(activeStyle())
1530
+ });
1531
+ } else {
1532
+ const suffix = url.length > 0 && url !== label ? ` (${url})` : "";
1533
+ elements.push({
1534
+ type: "text",
1535
+ text: `${label}${suffix}`,
1536
+ style: styleOrUndefined(activeStyle())
1537
+ });
1538
+ }
1365
1539
  }
1366
1540
  break;
1367
1541
  }
@@ -1371,7 +1545,7 @@ function emitPhrasing(nodes, ctx, baseStyle = {}) {
1371
1545
  elements.push({
1372
1546
  type: "text",
1373
1547
  text: node.alt ?? node.identifier,
1374
- style: styleOrUndefined(baseStyle)
1548
+ style: styleOrUndefined(activeStyle())
1375
1549
  });
1376
1550
  break;
1377
1551
  }
@@ -1385,7 +1559,7 @@ function emitPhrasing(nodes, ctx, baseStyle = {}) {
1385
1559
  }
1386
1560
  ],
1387
1561
  ctx,
1388
- baseStyle
1562
+ activeStyle()
1389
1563
  );
1390
1564
  elements.push(...asImage.elements);
1391
1565
  hoistedImages.push(...asImage.hoistedImages);
@@ -1397,7 +1571,7 @@ function emitPhrasing(nodes, ctx, baseStyle = {}) {
1397
1571
  elements.push({
1398
1572
  type: "text",
1399
1573
  text: `[${String(n)}]`,
1400
- style: styleOrUndefined(baseStyle)
1574
+ style: styleOrUndefined(activeStyle())
1401
1575
  });
1402
1576
  break;
1403
1577
  }
@@ -1405,7 +1579,7 @@ function emitPhrasing(nodes, ctx, baseStyle = {}) {
1405
1579
  elements.push({
1406
1580
  type: "text",
1407
1581
  text: node.value,
1408
- style: styleOrUndefined({ ...baseStyle, code: true })
1582
+ style: styleOrUndefined({ ...activeStyle(), code: true })
1409
1583
  });
1410
1584
  ctx.degradations.add({
1411
1585
  nodeType: "inlineMath",
@@ -1419,7 +1593,7 @@ function emitPhrasing(nodes, ctx, baseStyle = {}) {
1419
1593
  default: {
1420
1594
  const text = toString2(node);
1421
1595
  if (text.length > 0) {
1422
- elements.push({ type: "text", text, style: styleOrUndefined(baseStyle) });
1596
+ elements.push({ type: "text", text, style: styleOrUndefined(activeStyle()) });
1423
1597
  }
1424
1598
  break;
1425
1599
  }
@@ -1488,7 +1662,7 @@ function truncateLabel(label, max) {
1488
1662
  if (label.length <= max) {
1489
1663
  return { text: label, truncated: false };
1490
1664
  }
1491
- return { text: `${label.slice(0, Math.max(0, max - 1))}${ELLIPSIS}`, truncated: true };
1665
+ return { text: `${sliceSurrogateSafe(label, Math.max(0, max - 1))}${ELLIPSIS}`, truncated: true };
1492
1666
  }
1493
1667
  function hoistedImageBlock(h, ctx) {
1494
1668
  return {
@@ -1509,7 +1683,7 @@ function clampSectionText(text, ctx, nodeType) {
1509
1683
  reason: "Section text truncated to 3000 chars",
1510
1684
  path: ctx.path
1511
1685
  });
1512
- return text.slice(0, SECTION_TEXT_MAX_CHARS);
1686
+ return sliceSurrogateSafe(text, SECTION_TEXT_MAX_CHARS);
1513
1687
  }
1514
1688
  function preformattedRichText(text, language) {
1515
1689
  return {
@@ -1535,7 +1709,7 @@ function clampImageText(value, ctx, field) {
1535
1709
  reason: `Image ${field} truncated to ${String(IMAGE_ALT_MAX_CHARS)} chars`,
1536
1710
  path: ctx.path
1537
1711
  });
1538
- return value.slice(0, IMAGE_ALT_MAX_CHARS);
1712
+ return sliceSurrogateSafe(value, IMAGE_ALT_MAX_CHARS);
1539
1713
  }
1540
1714
  function styleOrUndefined(style) {
1541
1715
  if (style.bold === true || style.italic === true || style.strike === true || style.code === true || style.underline === true) {
@@ -1630,7 +1804,7 @@ function slackEntitySplit(value, style, emit) {
1630
1804
  function slackEntityRichTextInlines(value, style) {
1631
1805
  return slackEntitySplit(value, style, (slice) => emitTextWithEmoji(slice, style));
1632
1806
  }
1633
- var MENTION_RE = /(?<![A-Za-z0-9._-])@([a-zA-Z0-9._-]{1,64})|(?<![A-Za-z0-9_-])#([a-zA-Z0-9_-]{1,80})/g;
1807
+ var MENTION_RE = /(?<![A-Za-z0-9._-])@([a-zA-Z0-9_-](?:[a-zA-Z0-9._-]{0,62}[a-zA-Z0-9_-])?)|(?<![A-Za-z0-9_-])#([a-zA-Z0-9_-]{1,80})/g;
1634
1808
  function phrasingNeedsMentionResolution(nodes, ctx) {
1635
1809
  const resolvers = ctx.config.mentionResolvers;
1636
1810
  if (resolvers?.resolveUser === void 0 && resolvers?.resolveChannel === void 0) {
@@ -1707,13 +1881,8 @@ function emitTextWithEmoji(value, style) {
1707
1881
  if (start > last) {
1708
1882
  out.push({ type: "text", text: value.slice(last, start), style: styleOrUndefined(style) });
1709
1883
  }
1710
- const name = match[1] ?? "";
1711
- if (KNOWN_SLACK_EMOJI.has(name)) {
1712
- const emoji = { type: "emoji", name };
1713
- out.push(emoji);
1714
- } else {
1715
- out.push({ type: "text", text: match[0], style: styleOrUndefined(style) });
1716
- }
1884
+ const emoji = { type: "emoji", name: match[1] ?? "" };
1885
+ out.push(emoji);
1717
1886
  last = start + match[0].length;
1718
1887
  match = EMOJI_SHORTCODE_RE.exec(value);
1719
1888
  }
@@ -1780,7 +1949,7 @@ function emitLink(node, ctx, style) {
1780
1949
  reason: "Invalid or oversized URL",
1781
1950
  path: ctx.path
1782
1951
  });
1783
- const suffix = url.length > 0 ? ` (${url})` : "";
1952
+ const suffix = url.length > 0 && url !== text ? ` (${url})` : "";
1784
1953
  return [{ type: "text", text: `${text}${suffix}`, style: styleOrUndefined(style) }];
1785
1954
  }
1786
1955
  const link = {
@@ -1817,9 +1986,45 @@ function isValidLinkUrl(url) {
1817
1986
  if (url.length === 0 || url.length > IMAGE_URL_MAX_CHARS) {
1818
1987
  return false;
1819
1988
  }
1820
- return HTTP_URL_RE.test(url) || url.startsWith("#") || url.startsWith("/");
1989
+ return HTTP_URL_RE.test(url);
1821
1990
  }
1822
- var INLINE_TAG_RE = /<\/?(b|strong|i|em|s|del|code|br|a)(\s+[^>]{0,200})?>|[^<]+/gi;
1991
+ function anchorInline(anchor, ctx) {
1992
+ const label = anchor.textParts.join("");
1993
+ if (anchor.valid) {
1994
+ return {
1995
+ type: "link",
1996
+ url: anchor.href,
1997
+ text: label.length > 0 ? label : anchor.href,
1998
+ style: styleOrUndefined(anchor.style)
1999
+ };
2000
+ }
2001
+ ctx.degradations.add({
2002
+ nodeType: "link",
2003
+ from: "link",
2004
+ to: "text",
2005
+ reason: "Invalid or oversized URL",
2006
+ path: ctx.path
2007
+ });
2008
+ const base = label.length > 0 ? label : anchor.href;
2009
+ const suffix = label.length > 0 && anchor.href.length > 0 && anchor.href !== label ? ` (${anchor.href})` : "";
2010
+ return { type: "text", text: `${base}${suffix}`, style: styleOrUndefined(anchor.style) };
2011
+ }
2012
+ var HTML_STYLE_OPEN_RE = /^<(b|strong|i|em|s|del|code)(\s+[^>]{0,200})?>$/i;
2013
+ var HTML_STYLE_CLOSE_RE = /^<\/(b|strong|i|em|s|del|code)\s*>$/i;
2014
+ function htmlTagStyle(base, tag) {
2015
+ const t = tag.toLowerCase();
2016
+ if (t === "b" || t === "strong") {
2017
+ return { ...base, bold: true };
2018
+ }
2019
+ if (t === "i" || t === "em") {
2020
+ return { ...base, italic: true };
2021
+ }
2022
+ if (t === "s" || t === "del") {
2023
+ return { ...base, strike: true };
2024
+ }
2025
+ return { ...base, code: true };
2026
+ }
2027
+ var INLINE_TAG_RE = /<\/?(b|strong|i|em|s|del|code|br|a)(\s+[^>]{0,200})?\s*\/?>|<[^>]{0,200}>|[^<]+|</gi;
1823
2028
  function emitInlineHtml(html, style, ctx) {
1824
2029
  const out = [];
1825
2030
  const stack = [];
@@ -1827,11 +2032,15 @@ function emitInlineHtml(html, style, ctx) {
1827
2032
  let openAnchor;
1828
2033
  INLINE_TAG_RE.lastIndex = 0;
1829
2034
  let m = INLINE_TAG_RE.exec(html);
1830
- let matched = false;
2035
+ let strippedUnknown = false;
1831
2036
  while (m !== null) {
1832
- matched = true;
1833
2037
  const token = m[0];
1834
- if (token.startsWith("<")) {
2038
+ if (token.startsWith("<") && token.length > 1 && m[1] === void 0) {
2039
+ strippedUnknown = true;
2040
+ m = INLINE_TAG_RE.exec(html);
2041
+ continue;
2042
+ }
2043
+ if (token.startsWith("<") && m[1] !== void 0) {
1835
2044
  const tag = (m[1] ?? "").toLowerCase();
1836
2045
  const closing = token.startsWith("</");
1837
2046
  if (tag === "br") {
@@ -1842,13 +2051,7 @@ function emitInlineHtml(html, style, ctx) {
1842
2051
  }
1843
2052
  } else if (closing) {
1844
2053
  if (tag === "a" && openAnchor !== void 0) {
1845
- const label = openAnchor.textParts.join("");
1846
- out.push({
1847
- type: "link",
1848
- url: openAnchor.href,
1849
- text: label.length > 0 ? label : openAnchor.href,
1850
- style: styleOrUndefined(openAnchor.style)
1851
- });
2054
+ out.push(anchorInline(openAnchor, ctx));
1852
2055
  openAnchor = void 0;
1853
2056
  }
1854
2057
  stack.pop();
@@ -1857,8 +2060,11 @@ function emitInlineHtml(html, style, ctx) {
1857
2060
  const hrefMatch = /href\s*=\s*["']([^"']{0,3000})["']/i.exec(token);
1858
2061
  const href = hrefMatch?.[1];
1859
2062
  stack.push({ tag, style: current });
1860
- if (href !== void 0 && isValidLinkUrl(href)) {
1861
- openAnchor = { href, style: current, textParts: [] };
2063
+ if (href !== void 0) {
2064
+ if (openAnchor !== void 0) {
2065
+ out.push(anchorInline(openAnchor, ctx));
2066
+ }
2067
+ openAnchor = { href, style: current, textParts: [], valid: isValidLinkUrl(href) };
1862
2068
  }
1863
2069
  } else {
1864
2070
  const next = { ...current };
@@ -1879,15 +2085,9 @@ function emitInlineHtml(html, style, ctx) {
1879
2085
  m = INLINE_TAG_RE.exec(html);
1880
2086
  }
1881
2087
  if (openAnchor !== void 0) {
1882
- const label = openAnchor.textParts.join("");
1883
- out.push({
1884
- type: "link",
1885
- url: openAnchor.href,
1886
- text: label.length > 0 ? label : openAnchor.href,
1887
- style: styleOrUndefined(openAnchor.style)
1888
- });
2088
+ out.push(anchorInline(openAnchor, ctx));
1889
2089
  }
1890
- if (!matched) {
2090
+ if (strippedUnknown) {
1891
2091
  ctx.degradations.add({
1892
2092
  nodeType: "html",
1893
2093
  from: "html",
@@ -1895,10 +2095,6 @@ function emitInlineHtml(html, style, ctx) {
1895
2095
  reason: "Unrecognized inline HTML stripped to text",
1896
2096
  path: ctx.path
1897
2097
  });
1898
- const stripped = html.replace(/<[^>]{0,200}>/g, "");
1899
- if (stripped.length > 0) {
1900
- out.push({ type: "text", text: stripped, style: styleOrUndefined(style) });
1901
- }
1902
2098
  }
1903
2099
  return out;
1904
2100
  }
@@ -2130,10 +2326,13 @@ function emitPlainHeader(heading, withLevel) {
2130
2326
  const block = {
2131
2327
  type: "header",
2132
2328
  text: { type: "plain_text", text: plain.length > 0 ? plain : " " },
2133
- level: withLevel ? heading.depth : void 0
2329
+ level: withLevel ? headerLevelOf(heading.depth) : void 0
2134
2330
  };
2135
2331
  return [block];
2136
2332
  }
2333
+ function headerLevelOf(depth) {
2334
+ return depth === 1 || depth === 2 || depth === 3 || depth === 4 ? depth : void 0;
2335
+ }
2137
2336
  function emitRichHeading(heading, ctx, hoistImages) {
2138
2337
  const plain = phrasingToPlain(heading.children);
2139
2338
  const depth = heading.depth;
@@ -2296,7 +2495,7 @@ function clampContainerTitle(text, ctx) {
2296
2495
  reason: `Container title truncated to ${String(CONTAINER_TITLE_MAX_CHARS)} chars`,
2297
2496
  path: ctx.path
2298
2497
  });
2299
- return text.slice(0, CONTAINER_TITLE_MAX_CHARS);
2498
+ return sliceSurrogateSafe(text, CONTAINER_TITLE_MAX_CHARS);
2300
2499
  }
2301
2500
 
2302
2501
  // src/adapters/list-adapter.ts
@@ -2376,27 +2575,51 @@ function emitListTree(list, indent, ctx) {
2376
2575
  });
2377
2576
  effectiveIndent = RICH_TEXT_LIST_INDENT_MAX;
2378
2577
  }
2379
- const sections = [];
2380
- const nested = [];
2578
+ const ordered = list.ordered === true;
2579
+ const startOffset = ordered && list.start !== null && list.start !== void 0 && list.start > 1 ? list.start - 1 : 0;
2580
+ const out = [];
2381
2581
  const hoistedImages = [];
2382
- for (const item of list.children) {
2582
+ let pendingSections = [];
2583
+ const flush = (lastIndex) => {
2584
+ if (pendingSections.length === 0) {
2585
+ return;
2586
+ }
2587
+ const chunkStartIndex = lastIndex - pendingSections.length + 1;
2588
+ const offset = startOffset + chunkStartIndex;
2589
+ out.push({
2590
+ type: "rich_text_list",
2591
+ style: ordered ? "ordered" : "bullet",
2592
+ elements: pendingSections,
2593
+ indent: effectiveIndent,
2594
+ offset: ordered && offset > 0 ? offset : void 0
2595
+ });
2596
+ pendingSections = [];
2597
+ };
2598
+ for (const [index, item] of list.children.entries()) {
2383
2599
  const { section, childLists, hoistedImages: itemImages } = emitListItem(item, ctx);
2384
- sections.push(section);
2600
+ pendingSections.push(section);
2385
2601
  hoistedImages.push(...itemImages);
2602
+ if (childLists.length === 0) {
2603
+ continue;
2604
+ }
2605
+ flush(index);
2386
2606
  for (const child of childLists) {
2387
2607
  const nestedResult = emitListTree(child, effectiveIndent + 1, ctx);
2388
- nested.push(...nestedResult.elements);
2608
+ out.push(...nestedResult.elements);
2389
2609
  hoistedImages.push(...nestedResult.hoistedImages);
2390
2610
  }
2391
2611
  }
2392
- const primary = {
2393
- type: "rich_text_list",
2394
- style: list.ordered === true ? "ordered" : "bullet",
2395
- elements: sections.length > 0 ? sections : [{ type: "rich_text_section", elements: [{ type: "text", text: " " }] }],
2396
- indent: effectiveIndent,
2397
- offset: list.ordered === true && list.start !== null && list.start !== void 0 && list.start > 1 ? list.start - 1 : void 0
2398
- };
2399
- return { elements: [primary, ...nested], hoistedImages };
2612
+ flush(list.children.length - 1);
2613
+ if (out.length === 0) {
2614
+ out.push({
2615
+ type: "rich_text_list",
2616
+ style: ordered ? "ordered" : "bullet",
2617
+ elements: [{ type: "rich_text_section", elements: [{ type: "text", text: " " }] }],
2618
+ indent: effectiveIndent,
2619
+ offset: void 0
2620
+ });
2621
+ }
2622
+ return { elements: out, hoistedImages };
2400
2623
  }
2401
2624
  function collectListItemPhrasing(item) {
2402
2625
  const phrasing = [];
@@ -2713,7 +2936,19 @@ async function tryRenderers(source, ctx) {
2713
2936
  if (!renderer.canRender(req)) {
2714
2937
  continue;
2715
2938
  }
2716
- const result = await renderer.render(req);
2939
+ let result;
2940
+ try {
2941
+ result = await renderer.render(req);
2942
+ } catch (err) {
2943
+ ctx.degradations.add({
2944
+ nodeType: "code.mermaid",
2945
+ from: "image.render",
2946
+ to: "skip",
2947
+ reason: `Renderer failed: ${err instanceof Error ? err.message : String(err)}`,
2948
+ path: ctx.path
2949
+ });
2950
+ continue;
2951
+ }
2717
2952
  if (result === null) {
2718
2953
  continue;
2719
2954
  }
@@ -2745,7 +2980,19 @@ async function tryRenderers(source, ctx) {
2745
2980
  });
2746
2981
  continue;
2747
2982
  }
2748
- const uploaded = await uploader.upload(result);
2983
+ let uploaded;
2984
+ try {
2985
+ uploaded = await uploader.upload(result);
2986
+ } catch (err) {
2987
+ ctx.degradations.add({
2988
+ nodeType: "code.mermaid",
2989
+ from: "image.bytes",
2990
+ to: "skip",
2991
+ reason: `Slack upload failed: ${err instanceof Error ? err.message : String(err)}`,
2992
+ path: ctx.path
2993
+ });
2994
+ continue;
2995
+ }
2749
2996
  return {
2750
2997
  type: "image",
2751
2998
  alt_text: clampImageText(result.altText, ctx, "alt_text"),
@@ -2808,11 +3055,11 @@ function emitParagraph(paragraph, emitCtx, loneImage, hoistImages) {
2808
3055
  const img = loneImage;
2809
3056
  if (isPublicImageUrl(img.url)) {
2810
3057
  const altRaw = img.alt ?? "image";
2811
- const alt = clampImageText(altRaw.length > 0 ? altRaw : "image", emitCtx, "alt_text");
3058
+ const alt2 = clampImageText(altRaw.length > 0 ? altRaw : "image", emitCtx, "alt_text");
2812
3059
  const block = {
2813
3060
  type: "image",
2814
3061
  image_url: img.url,
2815
- alt_text: alt,
3062
+ alt_text: alt2,
2816
3063
  title: img.title !== null && img.title !== void 0 && img.title.length > 0 ? {
2817
3064
  type: "plain_text",
2818
3065
  text: clampImageText(img.title, emitCtx, "title")
@@ -2820,20 +3067,23 @@ function emitParagraph(paragraph, emitCtx, loneImage, hoistImages) {
2820
3067
  };
2821
3068
  return [block];
2822
3069
  }
3070
+ const linkable = isValidLinkUrl(img.url);
2823
3071
  emitCtx.degradations.add({
2824
3072
  nodeType: "image",
2825
3073
  from: "image",
2826
- to: "section.link",
3074
+ to: linkable ? "section.link" : "section.text",
2827
3075
  reason: "Non-public or invalid image URL",
2828
3076
  path: emitCtx.path
2829
3077
  });
2830
- const linkText = `<${img.url}|${img.alt ?? img.url}>`;
3078
+ const alt = img.alt ?? "";
3079
+ const label = alt.length > 0 ? alt : img.url.length > 0 ? img.url : "image";
3080
+ const fallbackText = linkable ? `<${img.url}|${label}>` : img.url.length > 0 && img.url !== label ? `${label} (${img.url})` : label;
2831
3081
  return [
2832
3082
  {
2833
3083
  type: "section",
2834
3084
  text: {
2835
3085
  type: "mrkdwn",
2836
- text: clampSectionText(linkText, emitCtx, "section")
3086
+ text: clampSectionText(fallbackText, emitCtx, "section")
2837
3087
  }
2838
3088
  }
2839
3089
  ];
@@ -2893,14 +3143,15 @@ var TableAdapter = class {
2893
3143
  return node.type === "table";
2894
3144
  }
2895
3145
  plan(node, ctx) {
2896
- const table = node;
3146
+ const table = normalizeTableRows(node, ctx);
2897
3147
  const rowCount = table.children.length;
2898
3148
  const colCount = table.children[0]?.children.length ?? 0;
2899
- const cellChars = countEmittedCellChars(table, ctx);
2900
3149
  if (colCount > TABLE_MAX_COLS) {
3150
+ const cellChars = countEmittedCellChars(table, ctx, "table");
2901
3151
  return markdownThenAscii(table, cellChars, "Table exceeds 20 columns");
2902
3152
  }
2903
3153
  if (rowCount <= TABLE_PREFERRED_MAX_ROWS) {
3154
+ const cellChars = countEmittedCellChars(table, ctx, "table");
2904
3155
  return [
2905
3156
  {
2906
3157
  requires: ["tableBlock"],
@@ -2915,11 +3166,12 @@ var TableAdapter = class {
2915
3166
  const dataRows = Math.max(0, rowCount - 1);
2916
3167
  if (dataRows <= DATA_TABLE_MAX_DATA_ROWS) {
2917
3168
  const preferredTable = truncateRows(table, TABLE_PREFERRED_MAX_ROWS);
2918
- const preferredChars = countEmittedCellChars(preferredTable, ctx);
3169
+ const preferredChars = countEmittedCellChars(preferredTable, ctx, "table");
3170
+ const dataTableChars = countEmittedCellChars(table, ctx, "data_table");
2919
3171
  return [
2920
3172
  {
2921
3173
  requires: ["dataTable"],
2922
- budget: { blocks: 1, dataTableChars: cellChars },
3174
+ budget: { blocks: 1, dataTableChars },
2923
3175
  emit: (emitCtx) => {
2924
3176
  emitCtx.degradations.add({
2925
3177
  nodeType: "table",
@@ -2945,11 +3197,11 @@ var TableAdapter = class {
2945
3197
  return [buildTableBlock(preferredTable, emitCtx)];
2946
3198
  }
2947
3199
  },
2948
- ...markdownThenAscii(table, cellChars, "native table unavailable")
3200
+ ...markdownThenAscii(table, dataTableChars, "native table unavailable")
2949
3201
  ];
2950
3202
  }
2951
3203
  const dataChunk = truncateRows(table, DATA_TABLE_MAX_DATA_ROWS + 1);
2952
- const dataChunkChars = countEmittedCellChars(dataChunk, ctx);
3204
+ const dataChunkChars = countEmittedCellChars(dataChunk, ctx, "data_table");
2953
3205
  return [
2954
3206
  {
2955
3207
  requires: ["dataTable"],
@@ -2965,10 +3217,37 @@ var TableAdapter = class {
2965
3217
  return [buildDataTable(dataChunk, emitCtx)];
2966
3218
  }
2967
3219
  },
2968
- ...markdownThenAscii(table, cellChars, "oversized table")
3220
+ ...markdownThenAscii(table, dataChunkChars, "oversized table")
2969
3221
  ];
2970
3222
  }
2971
3223
  };
3224
+ function normalizeTableRows(table, ctx) {
3225
+ const colCount = table.children[0]?.children.length ?? 0;
3226
+ if (colCount === 0 || table.children.every((row) => row.children.length === colCount)) {
3227
+ return table;
3228
+ }
3229
+ const children = table.children.map((row, rowIndex) => {
3230
+ if (row.children.length === colCount) {
3231
+ return row;
3232
+ }
3233
+ if (row.children.length > colCount) {
3234
+ ctx.degradations.add({
3235
+ nodeType: "table",
3236
+ from: `row[${String(rowIndex)}]: ${String(row.children.length)} cells`,
3237
+ to: `${String(colCount)} cells`,
3238
+ reason: "Cells beyond the header column count dropped (GFM ignores the excess)",
3239
+ path: ctx.path
3240
+ });
3241
+ return { ...row, children: row.children.slice(0, colCount) };
3242
+ }
3243
+ const padding = Array.from(
3244
+ { length: colCount - row.children.length },
3245
+ () => ({ type: "tableCell", children: [] })
3246
+ );
3247
+ return { ...row, children: [...row.children, ...padding] };
3248
+ });
3249
+ return { ...table, children };
3250
+ }
2972
3251
  function markdownThenAscii(table, cellChars, reason) {
2973
3252
  const md = toPipeMarkdown(table);
2974
3253
  return [
@@ -3014,7 +3293,7 @@ function buildTableBlock(table, ctx) {
3014
3293
  }
3015
3294
  function buildDataTable(table, ctx) {
3016
3295
  const captionRaw = ctx.precedingHeading !== void 0 && ctx.precedingHeading.length > 0 ? ctx.precedingHeading : "Table";
3017
- const caption = captionRaw.slice(0, 150);
3296
+ const caption = sliceSurrogateSafe(captionRaw, 150);
3018
3297
  if (captionRaw.length > 150) {
3019
3298
  ctx.degradations.add({
3020
3299
  nodeType: "table",
@@ -3025,12 +3304,7 @@ function buildDataTable(table, ctx) {
3025
3304
  });
3026
3305
  }
3027
3306
  const rows = table.children.map(
3028
- (row, rowIndex) => row.children.map((cell) => {
3029
- if (rowIndex === 0) {
3030
- return { type: "raw_text", text: toString5(cell) };
3031
- }
3032
- return cellToSlack(cell, ctx, false);
3033
- })
3307
+ (row, rowIndex) => row.children.map((cell) => cellForDataTable(cell, ctx, rowIndex))
3034
3308
  );
3035
3309
  return {
3036
3310
  type: "data_table",
@@ -3040,25 +3314,31 @@ function buildDataTable(table, ctx) {
3040
3314
  row_header_column_index: 0
3041
3315
  };
3042
3316
  }
3317
+ function cellForDataTable(cell, ctx, rowIndex) {
3318
+ if (rowIndex === 0) {
3319
+ return { type: "raw_text", text: toString5(cell) };
3320
+ }
3321
+ return cellToSlack(cell, ctx, false);
3322
+ }
3043
3323
  function cellToSlack(cell, ctx, header) {
3044
- const plain = toString5(cell);
3045
- const styled = cell.children.some(
3046
- (c) => c.type === "strong" || c.type === "emphasis" || c.type === "delete" || c.type === "link" || c.type === "inlineCode"
3047
- );
3048
- if (!styled) {
3049
- if (!header && /^-?\d+(\.\d+)?$/.test(plain.trim())) {
3050
- const value = Number(plain.trim());
3051
- return { type: "raw_number", value, text: plain.trim() };
3324
+ const inline = emitPhrasing(cell.children, ctx);
3325
+ const single = inline.elements.length === 1 ? inline.elements[0] : void 0;
3326
+ const isEmpty = inline.elements.length === 0;
3327
+ const isSingleUnstyledText = single?.type === "text" && single.style === void 0;
3328
+ const plainText = isEmpty ? toString5(cell) : isSingleUnstyledText ? single.text : void 0;
3329
+ if (plainText !== void 0) {
3330
+ const trimmed = plainText.trim();
3331
+ if (!header && /^-?\d+(\.\d+)?$/.test(trimmed)) {
3332
+ return { type: "raw_number", value: Number(trimmed), text: trimmed };
3052
3333
  }
3053
- return { type: "raw_text", text: plain };
3334
+ return { type: "raw_text", text: plainText };
3054
3335
  }
3055
- const inline = emitPhrasing(cell.children, ctx);
3056
3336
  const rich = {
3057
3337
  type: "rich_text",
3058
3338
  elements: [
3059
3339
  {
3060
3340
  type: "rich_text_section",
3061
- elements: inline.elements.length > 0 ? inline.elements : [{ type: "text", text: plain }]
3341
+ elements: inline.elements
3062
3342
  }
3063
3343
  ]
3064
3344
  };
@@ -3085,12 +3365,13 @@ function columnSettings(table) {
3085
3365
  }
3086
3366
  return settings;
3087
3367
  }
3088
- function countEmittedCellChars(table, ctx) {
3368
+ function countEmittedCellChars(table, ctx, mode) {
3089
3369
  const probe = ctx.withDegradations(new RecordingDegradationCollector());
3090
3370
  let n = 0;
3091
3371
  for (const [rowIndex, row] of table.children.entries()) {
3092
3372
  for (const cell of row.children) {
3093
- n += slackCellCharCount(cellToSlack(cell, probe, rowIndex === 0));
3373
+ const emitted = mode === "data_table" ? cellForDataTable(cell, probe, rowIndex) : cellToSlack(cell, probe, rowIndex === 0);
3374
+ n += slackCellCharCount(emitted);
3094
3375
  }
3095
3376
  }
3096
3377
  return n;
@@ -3213,27 +3494,50 @@ function emitUnicodeTaskTree(list, indent, ctx) {
3213
3494
  });
3214
3495
  effectiveIndent = RICH_TEXT_LIST_INDENT_MAX;
3215
3496
  }
3216
- const sections = [];
3217
- const nested = [];
3497
+ const out = [];
3498
+ let pendingSections = [];
3499
+ const flush = () => {
3500
+ if (pendingSections.length === 0) {
3501
+ return;
3502
+ }
3503
+ out.push({
3504
+ type: "rich_text_list",
3505
+ style: "bullet",
3506
+ elements: pendingSections,
3507
+ indent: effectiveIndent
3508
+ });
3509
+ pendingSections = [];
3510
+ };
3218
3511
  for (const item of list.children) {
3219
3512
  const prefix = item.checked === true ? "\u2611 " : item.checked === false ? "\u2610 " : "";
3220
- sections.push({
3513
+ pendingSections.push({
3221
3514
  type: "rich_text_section",
3222
3515
  elements: [{ type: "text", text: `${prefix}${itemText(item)}` }]
3223
3516
  });
3517
+ const childLists = [];
3224
3518
  for (const child of item.children) {
3225
3519
  if (child.type === "list") {
3226
- nested.push(...emitUnicodeTaskTree(child, effectiveIndent + 1, ctx));
3520
+ childLists.push(child);
3227
3521
  }
3228
3522
  }
3523
+ if (childLists.length === 0) {
3524
+ continue;
3525
+ }
3526
+ flush();
3527
+ for (const child of childLists) {
3528
+ out.push(...emitUnicodeTaskTree(child, effectiveIndent + 1, ctx));
3529
+ }
3229
3530
  }
3230
- const primary = {
3231
- type: "rich_text_list",
3232
- style: "bullet",
3233
- elements: sections.length > 0 ? sections : [{ type: "rich_text_section", elements: [{ type: "text", text: " " }] }],
3234
- indent: effectiveIndent
3235
- };
3236
- return [primary, ...nested];
3531
+ flush();
3532
+ if (out.length === 0) {
3533
+ out.push({
3534
+ type: "rich_text_list",
3535
+ style: "bullet",
3536
+ elements: [{ type: "rich_text_section", elements: [{ type: "text", text: " " }] }],
3537
+ indent: effectiveIndent
3538
+ });
3539
+ }
3540
+ return out;
3237
3541
  }
3238
3542
 
3239
3543
  // src/adapters/thematic-break-adapter.ts