@llblab/pi-telegram 0.2.8 → 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");
@@ -146,6 +355,282 @@ function isMarkdownNumberedListMarker(marker: string): boolean {
146
355
  return /^\d+\.$/.test(marker);
147
356
  }
148
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
+
149
634
  export function renderMarkdownPreviewText(markdown: string): string {
150
635
  const normalized = normalizeMarkdownDocument(markdown);
151
636
  if (normalized.length === 0) return "";
@@ -161,7 +646,7 @@ export function renderMarkdownPreviewText(markdown: string): string {
161
646
  continue;
162
647
  }
163
648
  if (line.trim().length === 0) {
164
- if (output.at(-1) !== "") output.push("");
649
+ output.push("");
165
650
  continue;
166
651
  }
167
652
  output.push(line);
@@ -172,15 +657,15 @@ export function renderMarkdownPreviewText(markdown: string): string {
172
657
  continue;
173
658
  }
174
659
  if (line.trim().length === 0) {
175
- if (output.at(-1) !== "") output.push("");
660
+ output.push("");
176
661
  continue;
177
662
  }
178
663
  if (isMarkdownTableSeparator(line)) {
179
664
  continue;
180
665
  }
181
- const heading = line.match(/^\s*#{1,6}\s+(.+)$/);
666
+ const heading = matchMarkdownHeadingLine(line);
182
667
  if (heading) {
183
- output.push(stripInlineMarkdownToPlainText(heading[1] ?? ""));
668
+ output.push(stripInlineMarkdownToPlainText(heading[2] ?? ""));
184
669
  continue;
185
670
  }
186
671
  const task = line.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/);
@@ -252,26 +737,24 @@ function renderInlineMarkdown(text: string): string {
252
737
  tokens.push(html);
253
738
  return token;
254
739
  };
255
- let result = text;
256
- result = result.replace(
257
- /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g,
258
- (_match, alt: string, url: string) => {
259
- const label = alt.trim().length > 0 ? alt : url;
260
- return makeToken(`<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`);
261
- },
262
- );
263
- result = result.replace(
264
- /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
265
- (_match, label: string, url: string) => {
266
- 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
+ );
267
751
  },
268
- );
269
- result = result.replace(
270
- /<((?:https?:\/\/|mailto:)[^>]+)>/g,
271
- (_match, url: string) => {
272
- 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
+ );
273
756
  },
274
- );
757
+ });
275
758
  result = result.replace(/`([^`\n]+)`/g, (_match, code: string) => {
276
759
  return makeToken(`<code>${escapeHtml(code)}</code>`);
277
760
  });
@@ -335,7 +818,7 @@ function renderMarkdownTextLines(block: string): string[] {
335
818
  if (line.trim().length === 0) continue;
336
819
  const pieces = splitPlainMarkdownLine(line);
337
820
  for (const piece of pieces) {
338
- const heading = piece.match(/^(\s*)#{1,6}\s+(.+)$/);
821
+ const heading = matchMarkdownHeadingLine(piece);
339
822
  if (heading) {
340
823
  rendered.push(
341
824
  `${buildListIndent(Math.floor((heading[1] ?? "").length / 2))}<b>${renderInlineMarkdown(heading[2] ?? "")}</b>`,
@@ -517,15 +1000,56 @@ function renderMarkdownQuoteBlock(lines: string[]): string[] {
517
1000
  });
518
1001
  }
519
1002
 
1003
+ interface TelegramRenderedBlockWithSpacing {
1004
+ text: string;
1005
+ blankLinesBefore: number;
1006
+ }
1007
+
520
1008
  function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
521
1009
  const normalized = normalizeMarkdownDocument(markdown);
522
1010
  if (normalized.length === 0) return [];
523
- 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
+ };
524
1029
  const lines = normalized.split("\n");
525
1030
  let index = 0;
1031
+ let pendingBlankLines = 0;
526
1032
  while (index < lines.length) {
527
1033
  const line = lines[index] ?? "";
528
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
+ }
529
1053
  const fence = parseMarkdownFence(line);
530
1054
  if (fence) {
531
1055
  index += 1;
@@ -540,16 +1064,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
540
1064
  if (index < lines.length) {
541
1065
  index += 1;
542
1066
  }
543
- renderedBlocks.push(
544
- ...renderMarkdownCodeBlock(codeLines.join("\n"), fence.info),
1067
+ pushRenderedBlocks(
1068
+ renderMarkdownCodeBlock(codeLines.join("\n"), fence.info),
1069
+ pendingBlankLines,
545
1070
  );
546
- while (index < lines.length && (lines[index] ?? "").trim().length === 0) {
547
- index += 1;
548
- }
549
- continue;
550
- }
551
- if (line.trim().length === 0) {
552
- index += 1;
1071
+ pendingBlankLines = 0;
553
1072
  continue;
554
1073
  }
555
1074
  if (line.includes("|") && isMarkdownTableSeparator(nextLine)) {
@@ -563,7 +1082,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
563
1082
  tableLines.push(tableLine);
564
1083
  index += 1;
565
1084
  }
566
- renderedBlocks.push(...renderMarkdownTableBlock(tableLines));
1085
+ pushRenderedBlocks(
1086
+ renderMarkdownTableBlock(tableLines),
1087
+ pendingBlankLines,
1088
+ );
1089
+ pendingBlankLines = 0;
567
1090
  continue;
568
1091
  }
569
1092
  if (canStartIndentedCodeBlock(lines, index)) {
@@ -579,7 +1102,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
579
1102
  codeLines.push(stripIndentedCodePrefix(rawLine));
580
1103
  index += 1;
581
1104
  }
582
- renderedBlocks.push(...renderMarkdownCodeBlock(codeLines.join("\n")));
1105
+ pushRenderedBlocks(
1106
+ renderMarkdownCodeBlock(codeLines.join("\n")),
1107
+ pendingBlankLines,
1108
+ );
1109
+ pendingBlankLines = 0;
583
1110
  continue;
584
1111
  }
585
1112
  if (/^\s*>/.test(line)) {
@@ -588,7 +1115,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
588
1115
  quoteLines.push(lines[index] ?? "");
589
1116
  index += 1;
590
1117
  }
591
- renderedBlocks.push(...renderMarkdownQuoteBlock(quoteLines));
1118
+ pushRenderedBlocks(
1119
+ renderMarkdownQuoteBlock(quoteLines),
1120
+ pendingBlankLines,
1121
+ );
1122
+ pendingBlankLines = 0;
592
1123
  continue;
593
1124
  }
594
1125
  const textLines: string[] = [];
@@ -606,12 +1137,18 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
606
1137
  textLines.push(current);
607
1138
  index += 1;
608
1139
  }
609
- renderedBlocks.push(...renderMarkdownTextBlock(textLines.join("\n")));
1140
+ pushRenderedBlocks(
1141
+ renderMarkdownTextBlock(textLines.join("\n")),
1142
+ pendingBlankLines,
1143
+ );
1144
+ pendingBlankLines = 0;
610
1145
  }
611
1146
  const chunks: string[] = [];
612
1147
  let current = "";
613
1148
  for (const block of renderedBlocks) {
614
- 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}`;
615
1152
  if (candidate.length <= MAX_MESSAGE_LENGTH) {
616
1153
  current = candidate;
617
1154
  continue;
@@ -620,12 +1157,12 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
620
1157
  chunks.push(current);
621
1158
  current = "";
622
1159
  }
623
- if (block.length <= MAX_MESSAGE_LENGTH) {
624
- current = block;
1160
+ if (block.text.length <= MAX_MESSAGE_LENGTH) {
1161
+ current = block.text;
625
1162
  continue;
626
1163
  }
627
- for (let i = 0; i < block.length; i += MAX_MESSAGE_LENGTH) {
628
- 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));
629
1166
  }
630
1167
  }
631
1168
  if (current.length > 0) {