@llblab/pi-telegram 0.2.7 → 0.2.9

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/lib/rendering.ts CHANGED
@@ -46,11 +46,220 @@ function splitPlainMarkdownLine(line: string, maxLength = 1500): string[] {
46
46
  return parts.length > 0 ? parts : [line];
47
47
  }
48
48
 
49
+ interface ParsedMarkdownInlineLink {
50
+ startIndex: number;
51
+ endIndex: number;
52
+ label: string;
53
+ destination: string;
54
+ isImage: boolean;
55
+ }
56
+
57
+ interface ParsedMarkdownAutolink {
58
+ startIndex: number;
59
+ endIndex: number;
60
+ destination: string;
61
+ }
62
+
63
+ function isEscapedMarkdownCharacter(text: string, index: number): boolean {
64
+ let backslashCount = 0;
65
+ for (let i = index - 1; i >= 0 && text[i] === "\\"; i--) {
66
+ backslashCount += 1;
67
+ }
68
+ return backslashCount % 2 === 1;
69
+ }
70
+
71
+ function findMarkdownClosingBracket(
72
+ text: string,
73
+ startIndex: number,
74
+ ): number | undefined {
75
+ let depth = 0;
76
+ for (let index = startIndex; index < text.length; index += 1) {
77
+ if (isEscapedMarkdownCharacter(text, index)) continue;
78
+ const char = text[index] ?? "";
79
+ if (char === "[") {
80
+ depth += 1;
81
+ continue;
82
+ }
83
+ if (char !== "]") continue;
84
+ depth -= 1;
85
+ if (depth === 0) return index;
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ function parseMarkdownLinkTarget(
91
+ text: string,
92
+ openParenIndex: number,
93
+ ): { destination: string; endIndex: number } | undefined {
94
+ let index = openParenIndex + 1;
95
+ while (index < text.length && /\s/.test(text[index] ?? "")) {
96
+ index += 1;
97
+ }
98
+ if (index >= text.length) return undefined;
99
+ let destination = "";
100
+ if (text[index] === "<") {
101
+ const destinationStart = index + 1;
102
+ index += 1;
103
+ while (index < text.length) {
104
+ if (!isEscapedMarkdownCharacter(text, index) && text[index] === ">") {
105
+ break;
106
+ }
107
+ index += 1;
108
+ }
109
+ if (index >= text.length) return undefined;
110
+ destination = text.slice(destinationStart, index).trim();
111
+ index += 1;
112
+ } else {
113
+ const destinationStart = index;
114
+ let parenDepth = 0;
115
+ while (index < text.length) {
116
+ if (isEscapedMarkdownCharacter(text, index)) {
117
+ index += 1;
118
+ continue;
119
+ }
120
+ const char = text[index] ?? "";
121
+ if (/\s/.test(char) && parenDepth === 0) break;
122
+ if (char === "(") {
123
+ parenDepth += 1;
124
+ index += 1;
125
+ continue;
126
+ }
127
+ if (char === ")") {
128
+ if (parenDepth === 0) break;
129
+ parenDepth -= 1;
130
+ index += 1;
131
+ continue;
132
+ }
133
+ index += 1;
134
+ }
135
+ destination = text.slice(destinationStart, index).trim();
136
+ }
137
+ if (!destination) return undefined;
138
+ while (index < text.length && /\s/.test(text[index] ?? "")) {
139
+ index += 1;
140
+ }
141
+ if (
142
+ index < text.length &&
143
+ (text[index] === '"' || text[index] === "'" || text[index] === "(")
144
+ ) {
145
+ const titleDelimiter = text[index] ?? '"';
146
+ const closingTitleDelimiter = titleDelimiter === "(" ? ")" : titleDelimiter;
147
+ index += 1;
148
+ while (index < text.length) {
149
+ if (
150
+ !isEscapedMarkdownCharacter(text, index) &&
151
+ text[index] === closingTitleDelimiter
152
+ ) {
153
+ break;
154
+ }
155
+ index += 1;
156
+ }
157
+ if (index >= text.length) return undefined;
158
+ index += 1;
159
+ while (index < text.length && /\s/.test(text[index] ?? "")) {
160
+ index += 1;
161
+ }
162
+ }
163
+ if (text[index] !== ")") return undefined;
164
+ return { destination, endIndex: index };
165
+ }
166
+
167
+ function isSupportedMarkdownLinkDestination(destination: string): boolean {
168
+ return /^(?:https?:\/\/|mailto:)/i.test(destination.trim());
169
+ }
170
+
171
+ function parseMarkdownInlineLinkAt(
172
+ text: string,
173
+ index: number,
174
+ ): ParsedMarkdownInlineLink | undefined {
175
+ const isImage = text[index] === "!" && text[index + 1] === "[";
176
+ const labelStartIndex = isImage ? index + 1 : index;
177
+ if (text[labelStartIndex] !== "[") return undefined;
178
+ if (
179
+ isEscapedMarkdownCharacter(text, labelStartIndex) ||
180
+ (isImage && isEscapedMarkdownCharacter(text, index))
181
+ ) {
182
+ return undefined;
183
+ }
184
+ const labelEndIndex = findMarkdownClosingBracket(text, labelStartIndex);
185
+ if (labelEndIndex === undefined || text[labelEndIndex + 1] !== "(") {
186
+ return undefined;
187
+ }
188
+ const target = parseMarkdownLinkTarget(text, labelEndIndex + 1);
189
+ if (!target) return undefined;
190
+ return {
191
+ startIndex: index,
192
+ endIndex: target.endIndex,
193
+ label: text.slice(labelStartIndex + 1, labelEndIndex),
194
+ destination: target.destination,
195
+ isImage,
196
+ };
197
+ }
198
+
199
+ function parseMarkdownAutolinkAt(
200
+ text: string,
201
+ index: number,
202
+ ): ParsedMarkdownAutolink | undefined {
203
+ if (text[index] !== "<" || isEscapedMarkdownCharacter(text, index)) {
204
+ return undefined;
205
+ }
206
+ let endIndex = index + 1;
207
+ while (endIndex < text.length) {
208
+ if (!isEscapedMarkdownCharacter(text, endIndex) && text[endIndex] === ">") {
209
+ break;
210
+ }
211
+ endIndex += 1;
212
+ }
213
+ if (endIndex >= text.length) return undefined;
214
+ const destination = text.slice(index + 1, endIndex).trim();
215
+ if (!isSupportedMarkdownLinkDestination(destination)) {
216
+ return undefined;
217
+ }
218
+ return { startIndex: index, endIndex, destination };
219
+ }
220
+
221
+ function replaceMarkdownLinkLike(
222
+ text: string,
223
+ options: {
224
+ renderInlineLink: (
225
+ link: ParsedMarkdownInlineLink,
226
+ supported: boolean,
227
+ ) => string;
228
+ renderAutolink: (link: ParsedMarkdownAutolink) => string;
229
+ },
230
+ ): string {
231
+ let result = "";
232
+ for (let index = 0; index < text.length; ) {
233
+ const inlineLink = parseMarkdownInlineLinkAt(text, index);
234
+ if (inlineLink) {
235
+ result += options.renderInlineLink(
236
+ inlineLink,
237
+ isSupportedMarkdownLinkDestination(inlineLink.destination),
238
+ );
239
+ index = inlineLink.endIndex + 1;
240
+ continue;
241
+ }
242
+ const autolink = parseMarkdownAutolinkAt(text, index);
243
+ if (autolink) {
244
+ result += options.renderAutolink(autolink);
245
+ index = autolink.endIndex + 1;
246
+ continue;
247
+ }
248
+ result += text[index] ?? "";
249
+ index += 1;
250
+ }
251
+ return result;
252
+ }
253
+
49
254
  function stripInlineMarkdownToPlainText(text: string): string {
50
- let result = text;
51
- result = result.replace(/!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g, "$1");
52
- result = result.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, "$1");
53
- result = result.replace(/<((?:https?:\/\/|mailto:)[^>]+)>/g, "$1");
255
+ let result = replaceMarkdownLinkLike(text, {
256
+ renderInlineLink: (link, supported) => {
257
+ const plainLabel = stripInlineMarkdownToPlainText(link.label).trim();
258
+ if (plainLabel.length > 0) return plainLabel;
259
+ return supported ? link.destination : "";
260
+ },
261
+ renderAutolink: (link) => link.destination,
262
+ });
54
263
  result = result.replace(/`([^`\n]+)`/g, "$1");
55
264
  result = result.replace(/(\*\*\*|___)(.+?)\1/g, "$2");
56
265
  result = result.replace(/(\*\*|__)(.+?)\1/g, "$2");
@@ -129,8 +338,301 @@ function stripIndentedCodePrefix(line: string): string {
129
338
  return line;
130
339
  }
131
340
 
341
+ function normalizeMarkdownDocument(markdown: string): string {
342
+ const lines = markdown.replace(/\r\n/g, "\n").split("\n");
343
+ let start = 0;
344
+ while (start < lines.length && (lines[start] ?? "").trim().length === 0) {
345
+ start += 1;
346
+ }
347
+ let end = lines.length;
348
+ while (end > start && (lines[end - 1] ?? "").trim().length === 0) {
349
+ end -= 1;
350
+ }
351
+ return lines.slice(start, end).join("\n");
352
+ }
353
+
354
+ function isMarkdownNumberedListMarker(marker: string): boolean {
355
+ return /^\d+\.$/.test(marker);
356
+ }
357
+
358
+ function matchMarkdownHeadingLine(line: string): RegExpMatchArray | null {
359
+ return line.match(/^(\s*)#{1,6}\s+(.+)$/);
360
+ }
361
+
362
+ function endsWithMarkdownHeadingLine(markdown: string): boolean {
363
+ const lines = markdown.split("\n");
364
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
365
+ const line = lines[index] ?? "";
366
+ if (line.trim().length === 0) continue;
367
+ return matchMarkdownHeadingLine(line) !== null;
368
+ }
369
+ return false;
370
+ }
371
+
372
+ function splitLeadingMarkdownBlankLines(markdown: string): {
373
+ blankLines: number;
374
+ remainingText: string;
375
+ } {
376
+ const lines = markdown.split("\n");
377
+ let start = 0;
378
+ while (start < lines.length && (lines[start] ?? "").trim().length === 0) {
379
+ start += 1;
380
+ }
381
+ return {
382
+ blankLines: start,
383
+ remainingText: lines.slice(start).join("\n"),
384
+ };
385
+ }
386
+
387
+ export type TelegramPreviewRenderStrategy = "plain" | "rich-stable-blocks";
388
+
389
+ export interface TelegramPreviewSnapshotStateLike {
390
+ pendingText: string;
391
+ lastSentText: string;
392
+ lastSentParseMode?: "HTML";
393
+ lastSentStrategy?: TelegramPreviewRenderStrategy;
394
+ }
395
+
396
+ export interface TelegramPreviewSnapshot extends TelegramRenderedChunk {
397
+ sourceText: string;
398
+ strategy: TelegramPreviewRenderStrategy;
399
+ }
400
+
401
+ export function buildTelegramPreviewFlushText(options: {
402
+ state: TelegramPreviewSnapshotStateLike;
403
+ maxMessageLength: number;
404
+ renderPreviewText: (markdown: string) => string;
405
+ }): string | undefined {
406
+ const rawText = options.state.pendingText.trim();
407
+ const previewText = options.renderPreviewText(rawText).trim();
408
+ if (!previewText || previewText === options.state.lastSentText) {
409
+ return undefined;
410
+ }
411
+ return previewText.length > options.maxMessageLength
412
+ ? previewText.slice(0, options.maxMessageLength)
413
+ : previewText;
414
+ }
415
+
416
+ function buildTelegramPlainPreviewSnapshot(options: {
417
+ sourceText: string;
418
+ state: TelegramPreviewSnapshotStateLike;
419
+ maxMessageLength: number;
420
+ renderPreviewText: (markdown: string) => string;
421
+ }): TelegramPreviewSnapshot | undefined {
422
+ const previewText = options.renderPreviewText(options.sourceText).trim();
423
+ if (!previewText) return undefined;
424
+ const truncatedPreviewText =
425
+ previewText.length > options.maxMessageLength
426
+ ? previewText.slice(0, options.maxMessageLength)
427
+ : previewText;
428
+ if (
429
+ truncatedPreviewText === options.state.lastSentText &&
430
+ options.state.lastSentStrategy === "plain"
431
+ ) {
432
+ return undefined;
433
+ }
434
+ return {
435
+ text: truncatedPreviewText,
436
+ sourceText: options.sourceText,
437
+ strategy: "plain",
438
+ };
439
+ }
440
+
441
+ interface TelegramStablePreviewSplit {
442
+ stableMarkdown: string;
443
+ unstableTail: string;
444
+ }
445
+
446
+ function splitTelegramStablePreviewMarkdown(
447
+ markdown: string,
448
+ ): TelegramStablePreviewSplit {
449
+ const normalized = normalizeMarkdownDocument(markdown);
450
+ if (normalized.length === 0) {
451
+ return { stableMarkdown: "", unstableTail: "" };
452
+ }
453
+ const lines = normalized.split("\n");
454
+ let index = 0;
455
+ let stableEndIndex = 0;
456
+ while (index < lines.length) {
457
+ while (index < lines.length && (lines[index] ?? "").trim().length === 0) {
458
+ index += 1;
459
+ }
460
+ if (index >= lines.length) break;
461
+ const blockStart = index;
462
+ const line = lines[index] ?? "";
463
+ const nextLine = lines[index + 1] ?? "";
464
+ const fence = parseMarkdownFence(line);
465
+ if (fence) {
466
+ index += 1;
467
+ while (
468
+ index < lines.length &&
469
+ !isMatchingMarkdownFence(lines[index] ?? "", fence)
470
+ ) {
471
+ index += 1;
472
+ }
473
+ if (index >= lines.length) {
474
+ return {
475
+ stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
476
+ unstableTail: lines.slice(stableEndIndex).join("\n"),
477
+ };
478
+ }
479
+ index += 1;
480
+ stableEndIndex = index;
481
+ continue;
482
+ }
483
+ if (line.includes("|") && isMarkdownTableSeparator(nextLine)) {
484
+ index += 2;
485
+ while (index < lines.length) {
486
+ const tableLine = lines[index] ?? "";
487
+ if (tableLine.trim().length === 0 || !tableLine.includes("|")) {
488
+ break;
489
+ }
490
+ index += 1;
491
+ }
492
+ if (index >= lines.length) {
493
+ return {
494
+ stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
495
+ unstableTail: lines.slice(stableEndIndex).join("\n"),
496
+ };
497
+ }
498
+ stableEndIndex = index;
499
+ continue;
500
+ }
501
+ if (canStartIndentedCodeBlock(lines, index)) {
502
+ while (index < lines.length) {
503
+ const rawLine = lines[index] ?? "";
504
+ if (rawLine.trim().length === 0) {
505
+ index += 1;
506
+ continue;
507
+ }
508
+ if (!isIndentedCodeLine(rawLine)) break;
509
+ index += 1;
510
+ }
511
+ if (index >= lines.length) {
512
+ return {
513
+ stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
514
+ unstableTail: lines.slice(stableEndIndex).join("\n"),
515
+ };
516
+ }
517
+ stableEndIndex = index;
518
+ continue;
519
+ }
520
+ if (/^\s*>/.test(line)) {
521
+ while (index < lines.length && /^\s*>/.test(lines[index] ?? "")) {
522
+ index += 1;
523
+ }
524
+ if (index >= lines.length) {
525
+ return {
526
+ stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
527
+ unstableTail: lines.slice(stableEndIndex).join("\n"),
528
+ };
529
+ }
530
+ stableEndIndex = index;
531
+ continue;
532
+ }
533
+ while (index < lines.length) {
534
+ const current = lines[index] ?? "";
535
+ const following = lines[index + 1] ?? "";
536
+ if (current.trim().length === 0) break;
537
+ if (
538
+ index !== blockStart &&
539
+ (isFencedCodeStart(current) ||
540
+ canStartIndentedCodeBlock(lines, index) ||
541
+ /^\s*>/.test(current) ||
542
+ (current.includes("|") && isMarkdownTableSeparator(following)))
543
+ ) {
544
+ break;
545
+ }
546
+ index += 1;
547
+ }
548
+ if (index >= lines.length) {
549
+ return {
550
+ stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
551
+ unstableTail: lines.slice(stableEndIndex).join("\n"),
552
+ };
553
+ }
554
+ stableEndIndex = index;
555
+ }
556
+ return {
557
+ stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
558
+ unstableTail: lines.slice(stableEndIndex).join("\n"),
559
+ };
560
+ }
561
+
562
+ export function buildTelegramPreviewSnapshot(options: {
563
+ state: TelegramPreviewSnapshotStateLike;
564
+ maxMessageLength: number;
565
+ renderPreviewText: (markdown: string) => string;
566
+ renderTelegramMessage: (
567
+ text: string,
568
+ options?: { mode?: TelegramRenderMode },
569
+ ) => TelegramRenderedChunk[];
570
+ }): TelegramPreviewSnapshot | undefined {
571
+ const sourceText = options.state.pendingText.trim();
572
+ if (!sourceText) return undefined;
573
+ const split = splitTelegramStablePreviewMarkdown(sourceText);
574
+ if (split.stableMarkdown.length === 0) {
575
+ return buildTelegramPlainPreviewSnapshot({
576
+ sourceText,
577
+ state: options.state,
578
+ maxMessageLength: options.maxMessageLength,
579
+ renderPreviewText: options.renderPreviewText,
580
+ });
581
+ }
582
+ const stableChunk = options.renderTelegramMessage(split.stableMarkdown, {
583
+ mode: "markdown",
584
+ })[0];
585
+ if (
586
+ !stableChunk ||
587
+ stableChunk.text.length === 0 ||
588
+ stableChunk.text.length > options.maxMessageLength
589
+ ) {
590
+ return buildTelegramPlainPreviewSnapshot({
591
+ sourceText,
592
+ state: options.state,
593
+ maxMessageLength: options.maxMessageLength,
594
+ renderPreviewText: options.renderPreviewText,
595
+ });
596
+ }
597
+ let previewText = stableChunk.text;
598
+ if (split.unstableTail.length > 0) {
599
+ const tail = splitLeadingMarkdownBlankLines(split.unstableTail);
600
+ const minimumBlankLinesBeforeTail = endsWithMarkdownHeadingLine(
601
+ split.stableMarkdown,
602
+ )
603
+ ? 1
604
+ : 0;
605
+ const blankLinesBeforeTail = Math.max(
606
+ tail.blankLines,
607
+ minimumBlankLinesBeforeTail,
608
+ );
609
+ const separator =
610
+ tail.remainingText.length > 0
611
+ ? "\n".repeat(blankLinesBeforeTail + 1)
612
+ : "";
613
+ const tailText = escapeHtml(tail.remainingText);
614
+ const candidate = `${previewText}${separator}${tailText}`;
615
+ if (candidate.length <= options.maxMessageLength) {
616
+ previewText = candidate;
617
+ }
618
+ }
619
+ if (
620
+ previewText === options.state.lastSentText &&
621
+ stableChunk.parseMode === options.state.lastSentParseMode &&
622
+ options.state.lastSentStrategy === "rich-stable-blocks"
623
+ ) {
624
+ return undefined;
625
+ }
626
+ return {
627
+ text: previewText,
628
+ parseMode: stableChunk.parseMode,
629
+ sourceText,
630
+ strategy: "rich-stable-blocks",
631
+ };
632
+ }
633
+
132
634
  export function renderMarkdownPreviewText(markdown: string): string {
133
- const normalized = markdown.replace(/\r\n/g, "\n").trim();
635
+ const normalized = normalizeMarkdownDocument(markdown);
134
636
  if (normalized.length === 0) return "";
135
637
  const output: string[] = [];
136
638
  const lines = normalized.split("\n");
@@ -144,7 +646,7 @@ export function renderMarkdownPreviewText(markdown: string): string {
144
646
  continue;
145
647
  }
146
648
  if (line.trim().length === 0) {
147
- if (output.at(-1) !== "") output.push("");
649
+ output.push("");
148
650
  continue;
149
651
  }
150
652
  output.push(line);
@@ -155,23 +657,28 @@ export function renderMarkdownPreviewText(markdown: string): string {
155
657
  continue;
156
658
  }
157
659
  if (line.trim().length === 0) {
158
- if (output.at(-1) !== "") output.push("");
660
+ output.push("");
159
661
  continue;
160
662
  }
161
663
  if (isMarkdownTableSeparator(line)) {
162
664
  continue;
163
665
  }
164
- const heading = line.match(/^\s*#{1,6}\s+(.+)$/);
666
+ const heading = matchMarkdownHeadingLine(line);
165
667
  if (heading) {
166
- output.push(stripInlineMarkdownToPlainText(heading[1] ?? ""));
668
+ output.push(stripInlineMarkdownToPlainText(heading[2] ?? ""));
167
669
  continue;
168
670
  }
169
671
  const task = line.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/);
170
672
  if (task) {
171
673
  const indent = " ".repeat((task[1] ?? "").length);
172
- const marker = (task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]";
674
+ const listMarker = task[2] ?? "-";
675
+ const checkboxMarker =
676
+ (task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]";
677
+ const taskPrefix = isMarkdownNumberedListMarker(listMarker)
678
+ ? `${listMarker} ${checkboxMarker}`
679
+ : checkboxMarker;
173
680
  output.push(
174
- `${indent}${marker} ${stripInlineMarkdownToPlainText(task[4] ?? "")}`,
681
+ `${indent}${taskPrefix} ${stripInlineMarkdownToPlainText(task[4] ?? "")}`,
175
682
  );
176
683
  continue;
177
684
  }
@@ -230,26 +737,24 @@ function renderInlineMarkdown(text: string): string {
230
737
  tokens.push(html);
231
738
  return token;
232
739
  };
233
- let result = text;
234
- result = result.replace(
235
- /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g,
236
- (_match, alt: string, url: string) => {
237
- const label = alt.trim().length > 0 ? alt : url;
238
- return makeToken(`<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`);
239
- },
240
- );
241
- result = result.replace(
242
- /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
243
- (_match, label: string, url: string) => {
244
- return makeToken(`<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`);
740
+ let result = replaceMarkdownLinkLike(text, {
741
+ renderInlineLink: (link, supported) => {
742
+ const plainLabel = stripInlineMarkdownToPlainText(link.label).trim();
743
+ if (!supported) {
744
+ return plainLabel.length > 0 ? plainLabel : link.destination;
745
+ }
746
+ const renderedLabel =
747
+ plainLabel.length > 0 ? plainLabel : link.destination;
748
+ return makeToken(
749
+ `<a href="${escapeHtml(link.destination)}">${escapeHtml(renderedLabel)}</a>`,
750
+ );
245
751
  },
246
- );
247
- result = result.replace(
248
- /<((?:https?:\/\/|mailto:)[^>]+)>/g,
249
- (_match, url: string) => {
250
- return makeToken(`<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>`);
752
+ renderAutolink: (link) => {
753
+ return makeToken(
754
+ `<a href="${escapeHtml(link.destination)}">${escapeHtml(link.destination)}</a>`,
755
+ );
251
756
  },
252
- );
757
+ });
253
758
  result = result.replace(/`([^`\n]+)`/g, (_match, code: string) => {
254
759
  return makeToken(`<code>${escapeHtml(code)}</code>`);
255
760
  });
@@ -275,13 +780,6 @@ function renderInlineMarkdown(text: string): string {
275
780
  result = renderDelimitedInlineStyle(result, "_", (content) => {
276
781
  return `<i>${content}</i>`;
277
782
  });
278
- result = result.replace(
279
- /(^|[\s>(])(\[(?: |x|X)\])(?=($|[\s<).,:;!?]))/g,
280
- (_match, prefix: string, checkbox: string) => {
281
- const normalized = checkbox.toLowerCase() === "[x]" ? "[x]" : "[ ]";
282
- return `${prefix}<code>${normalized}</code>`;
283
- },
284
- );
285
783
  result = result.replace(/\\([\\`*_{}\[\]()#+\-.!>~|])/g, "$1");
286
784
  return result.replace(
287
785
  /\uE000(\d+)\uE001/g,
@@ -320,7 +818,7 @@ function renderMarkdownTextLines(block: string): string[] {
320
818
  if (line.trim().length === 0) continue;
321
819
  const pieces = splitPlainMarkdownLine(line);
322
820
  for (const piece of pieces) {
323
- const heading = piece.match(/^(\s*)#{1,6}\s+(.+)$/);
821
+ const heading = matchMarkdownHeadingLine(piece);
324
822
  if (heading) {
325
823
  rendered.push(
326
824
  `${buildListIndent(Math.floor((heading[1] ?? "").length / 2))}<b>${renderInlineMarkdown(heading[2] ?? "")}</b>`,
@@ -330,9 +828,14 @@ function renderMarkdownTextLines(block: string): string[] {
330
828
  const task = piece.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/);
331
829
  if (task) {
332
830
  const indent = buildListIndent(Math.floor((task[1] ?? "").length / 2));
333
- const marker = (task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]";
831
+ const listMarker = task[2] ?? "-";
832
+ const checkboxMarker =
833
+ (task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]";
834
+ const taskPrefix = isMarkdownNumberedListMarker(listMarker)
835
+ ? `<code>${listMarker}</code> <code>${checkboxMarker}</code>`
836
+ : `<code>${checkboxMarker}</code>`;
334
837
  rendered.push(
335
- `${indent}<code>${marker}</code> ${renderInlineMarkdown(task[4] ?? "")}`,
838
+ `${indent}${taskPrefix} ${renderInlineMarkdown(task[4] ?? "")}`,
336
839
  );
337
840
  continue;
338
841
  }
@@ -497,15 +1000,56 @@ function renderMarkdownQuoteBlock(lines: string[]): string[] {
497
1000
  });
498
1001
  }
499
1002
 
1003
+ interface TelegramRenderedBlockWithSpacing {
1004
+ text: string;
1005
+ blankLinesBefore: number;
1006
+ }
1007
+
500
1008
  function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
501
- const normalized = markdown.replace(/\r\n/g, "\n").trim();
1009
+ const normalized = normalizeMarkdownDocument(markdown);
502
1010
  if (normalized.length === 0) return [];
503
- const renderedBlocks: string[] = [];
1011
+ const renderedBlocks: TelegramRenderedBlockWithSpacing[] = [];
1012
+ let minimumBlankLinesBeforeNextBlock = 0;
1013
+ const pushRenderedBlocks = (
1014
+ blocks: string[],
1015
+ blankLinesBefore: number,
1016
+ ): void => {
1017
+ const effectiveBlankLinesBefore =
1018
+ renderedBlocks.length === 0
1019
+ ? blankLinesBefore
1020
+ : Math.max(blankLinesBefore, minimumBlankLinesBeforeNextBlock);
1021
+ for (const [blockIndex, block] of blocks.entries()) {
1022
+ renderedBlocks.push({
1023
+ text: block,
1024
+ blankLinesBefore: blockIndex === 0 ? effectiveBlankLinesBefore : 0,
1025
+ });
1026
+ }
1027
+ minimumBlankLinesBeforeNextBlock = 0;
1028
+ };
504
1029
  const lines = normalized.split("\n");
505
1030
  let index = 0;
1031
+ let pendingBlankLines = 0;
506
1032
  while (index < lines.length) {
507
1033
  const line = lines[index] ?? "";
508
1034
  const nextLine = lines[index + 1] ?? "";
1035
+ if (line.trim().length === 0) {
1036
+ pendingBlankLines += 1;
1037
+ index += 1;
1038
+ continue;
1039
+ }
1040
+ const heading = matchMarkdownHeadingLine(line);
1041
+ if (heading) {
1042
+ pushRenderedBlocks(
1043
+ renderMarkdownTextBlock(line),
1044
+ renderedBlocks.length === 0
1045
+ ? pendingBlankLines
1046
+ : Math.max(pendingBlankLines, 1),
1047
+ );
1048
+ pendingBlankLines = 0;
1049
+ minimumBlankLinesBeforeNextBlock = 1;
1050
+ index += 1;
1051
+ continue;
1052
+ }
509
1053
  const fence = parseMarkdownFence(line);
510
1054
  if (fence) {
511
1055
  index += 1;
@@ -520,16 +1064,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
520
1064
  if (index < lines.length) {
521
1065
  index += 1;
522
1066
  }
523
- renderedBlocks.push(
524
- ...renderMarkdownCodeBlock(codeLines.join("\n"), fence.info),
1067
+ pushRenderedBlocks(
1068
+ renderMarkdownCodeBlock(codeLines.join("\n"), fence.info),
1069
+ pendingBlankLines,
525
1070
  );
526
- while (index < lines.length && (lines[index] ?? "").trim().length === 0) {
527
- index += 1;
528
- }
529
- continue;
530
- }
531
- if (line.trim().length === 0) {
532
- index += 1;
1071
+ pendingBlankLines = 0;
533
1072
  continue;
534
1073
  }
535
1074
  if (line.includes("|") && isMarkdownTableSeparator(nextLine)) {
@@ -543,7 +1082,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
543
1082
  tableLines.push(tableLine);
544
1083
  index += 1;
545
1084
  }
546
- renderedBlocks.push(...renderMarkdownTableBlock(tableLines));
1085
+ pushRenderedBlocks(
1086
+ renderMarkdownTableBlock(tableLines),
1087
+ pendingBlankLines,
1088
+ );
1089
+ pendingBlankLines = 0;
547
1090
  continue;
548
1091
  }
549
1092
  if (canStartIndentedCodeBlock(lines, index)) {
@@ -559,7 +1102,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
559
1102
  codeLines.push(stripIndentedCodePrefix(rawLine));
560
1103
  index += 1;
561
1104
  }
562
- renderedBlocks.push(...renderMarkdownCodeBlock(codeLines.join("\n")));
1105
+ pushRenderedBlocks(
1106
+ renderMarkdownCodeBlock(codeLines.join("\n")),
1107
+ pendingBlankLines,
1108
+ );
1109
+ pendingBlankLines = 0;
563
1110
  continue;
564
1111
  }
565
1112
  if (/^\s*>/.test(line)) {
@@ -568,7 +1115,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
568
1115
  quoteLines.push(lines[index] ?? "");
569
1116
  index += 1;
570
1117
  }
571
- renderedBlocks.push(...renderMarkdownQuoteBlock(quoteLines));
1118
+ pushRenderedBlocks(
1119
+ renderMarkdownQuoteBlock(quoteLines),
1120
+ pendingBlankLines,
1121
+ );
1122
+ pendingBlankLines = 0;
572
1123
  continue;
573
1124
  }
574
1125
  const textLines: string[] = [];
@@ -586,12 +1137,18 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
586
1137
  textLines.push(current);
587
1138
  index += 1;
588
1139
  }
589
- renderedBlocks.push(...renderMarkdownTextBlock(textLines.join("\n")));
1140
+ pushRenderedBlocks(
1141
+ renderMarkdownTextBlock(textLines.join("\n")),
1142
+ pendingBlankLines,
1143
+ );
1144
+ pendingBlankLines = 0;
590
1145
  }
591
1146
  const chunks: string[] = [];
592
1147
  let current = "";
593
1148
  for (const block of renderedBlocks) {
594
- const candidate = current.length === 0 ? block : `${current}\n\n${block}`;
1149
+ const separator = "\n".repeat(block.blankLinesBefore + 1);
1150
+ const candidate =
1151
+ current.length === 0 ? block.text : `${current}${separator}${block.text}`;
595
1152
  if (candidate.length <= MAX_MESSAGE_LENGTH) {
596
1153
  current = candidate;
597
1154
  continue;
@@ -600,12 +1157,12 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
600
1157
  chunks.push(current);
601
1158
  current = "";
602
1159
  }
603
- if (block.length <= MAX_MESSAGE_LENGTH) {
604
- current = block;
1160
+ if (block.text.length <= MAX_MESSAGE_LENGTH) {
1161
+ current = block.text;
605
1162
  continue;
606
1163
  }
607
- for (let i = 0; i < block.length; i += MAX_MESSAGE_LENGTH) {
608
- chunks.push(block.slice(i, i + MAX_MESSAGE_LENGTH));
1164
+ for (let i = 0; i < block.text.length; i += MAX_MESSAGE_LENGTH) {
1165
+ chunks.push(block.text.slice(i, i + MAX_MESSAGE_LENGTH));
609
1166
  }
610
1167
  }
611
1168
  if (current.length > 0) {