@llblab/pi-telegram 0.2.8 → 0.2.10

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
@@ -14,6 +14,10 @@ function escapeHtml(text: string): string {
14
14
  .replace(/>/g, ">");
15
15
  }
16
16
 
17
+ function escapeHtmlAttribute(text: string): string {
18
+ return escapeHtml(text).replace(/"/g, """).replace(/'/g, "'");
19
+ }
20
+
17
21
  // --- Plain Preview Rendering ---
18
22
 
19
23
  function splitPlainMarkdownLine(line: string, maxLength = 1500): string[] {
@@ -46,11 +50,220 @@ function splitPlainMarkdownLine(line: string, maxLength = 1500): string[] {
46
50
  return parts.length > 0 ? parts : [line];
47
51
  }
48
52
 
53
+ interface ParsedMarkdownInlineLink {
54
+ startIndex: number;
55
+ endIndex: number;
56
+ label: string;
57
+ destination: string;
58
+ isImage: boolean;
59
+ }
60
+
61
+ interface ParsedMarkdownAutolink {
62
+ startIndex: number;
63
+ endIndex: number;
64
+ destination: string;
65
+ }
66
+
67
+ function isEscapedMarkdownCharacter(text: string, index: number): boolean {
68
+ let backslashCount = 0;
69
+ for (let i = index - 1; i >= 0 && text[i] === "\\"; i--) {
70
+ backslashCount += 1;
71
+ }
72
+ return backslashCount % 2 === 1;
73
+ }
74
+
75
+ function findMarkdownClosingBracket(
76
+ text: string,
77
+ startIndex: number,
78
+ ): number | undefined {
79
+ let depth = 0;
80
+ for (let index = startIndex; index < text.length; index += 1) {
81
+ if (isEscapedMarkdownCharacter(text, index)) continue;
82
+ const char = text[index] ?? "";
83
+ if (char === "[") {
84
+ depth += 1;
85
+ continue;
86
+ }
87
+ if (char !== "]") continue;
88
+ depth -= 1;
89
+ if (depth === 0) return index;
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ function parseMarkdownLinkTarget(
95
+ text: string,
96
+ openParenIndex: number,
97
+ ): { destination: string; endIndex: number } | undefined {
98
+ let index = openParenIndex + 1;
99
+ while (index < text.length && /\s/.test(text[index] ?? "")) {
100
+ index += 1;
101
+ }
102
+ if (index >= text.length) return undefined;
103
+ let destination = "";
104
+ if (text[index] === "<") {
105
+ const destinationStart = index + 1;
106
+ index += 1;
107
+ while (index < text.length) {
108
+ if (!isEscapedMarkdownCharacter(text, index) && text[index] === ">") {
109
+ break;
110
+ }
111
+ index += 1;
112
+ }
113
+ if (index >= text.length) return undefined;
114
+ destination = text.slice(destinationStart, index).trim();
115
+ index += 1;
116
+ } else {
117
+ const destinationStart = index;
118
+ let parenDepth = 0;
119
+ while (index < text.length) {
120
+ if (isEscapedMarkdownCharacter(text, index)) {
121
+ index += 1;
122
+ continue;
123
+ }
124
+ const char = text[index] ?? "";
125
+ if (/\s/.test(char) && parenDepth === 0) break;
126
+ if (char === "(") {
127
+ parenDepth += 1;
128
+ index += 1;
129
+ continue;
130
+ }
131
+ if (char === ")") {
132
+ if (parenDepth === 0) break;
133
+ parenDepth -= 1;
134
+ index += 1;
135
+ continue;
136
+ }
137
+ index += 1;
138
+ }
139
+ destination = text.slice(destinationStart, index).trim();
140
+ }
141
+ if (!destination) return undefined;
142
+ while (index < text.length && /\s/.test(text[index] ?? "")) {
143
+ index += 1;
144
+ }
145
+ if (
146
+ index < text.length &&
147
+ (text[index] === '"' || text[index] === "'" || text[index] === "(")
148
+ ) {
149
+ const titleDelimiter = text[index] ?? '"';
150
+ const closingTitleDelimiter = titleDelimiter === "(" ? ")" : titleDelimiter;
151
+ index += 1;
152
+ while (index < text.length) {
153
+ if (
154
+ !isEscapedMarkdownCharacter(text, index) &&
155
+ text[index] === closingTitleDelimiter
156
+ ) {
157
+ break;
158
+ }
159
+ index += 1;
160
+ }
161
+ if (index >= text.length) return undefined;
162
+ index += 1;
163
+ while (index < text.length && /\s/.test(text[index] ?? "")) {
164
+ index += 1;
165
+ }
166
+ }
167
+ if (text[index] !== ")") return undefined;
168
+ return { destination, endIndex: index };
169
+ }
170
+
171
+ function isSupportedMarkdownLinkDestination(destination: string): boolean {
172
+ return /^(?:https?:\/\/|mailto:)/i.test(destination.trim());
173
+ }
174
+
175
+ function parseMarkdownInlineLinkAt(
176
+ text: string,
177
+ index: number,
178
+ ): ParsedMarkdownInlineLink | undefined {
179
+ const isImage = text[index] === "!" && text[index + 1] === "[";
180
+ const labelStartIndex = isImage ? index + 1 : index;
181
+ if (text[labelStartIndex] !== "[") return undefined;
182
+ if (
183
+ isEscapedMarkdownCharacter(text, labelStartIndex) ||
184
+ (isImage && isEscapedMarkdownCharacter(text, index))
185
+ ) {
186
+ return undefined;
187
+ }
188
+ const labelEndIndex = findMarkdownClosingBracket(text, labelStartIndex);
189
+ if (labelEndIndex === undefined || text[labelEndIndex + 1] !== "(") {
190
+ return undefined;
191
+ }
192
+ const target = parseMarkdownLinkTarget(text, labelEndIndex + 1);
193
+ if (!target) return undefined;
194
+ return {
195
+ startIndex: index,
196
+ endIndex: target.endIndex,
197
+ label: text.slice(labelStartIndex + 1, labelEndIndex),
198
+ destination: target.destination,
199
+ isImage,
200
+ };
201
+ }
202
+
203
+ function parseMarkdownAutolinkAt(
204
+ text: string,
205
+ index: number,
206
+ ): ParsedMarkdownAutolink | undefined {
207
+ if (text[index] !== "<" || isEscapedMarkdownCharacter(text, index)) {
208
+ return undefined;
209
+ }
210
+ let endIndex = index + 1;
211
+ while (endIndex < text.length) {
212
+ if (!isEscapedMarkdownCharacter(text, endIndex) && text[endIndex] === ">") {
213
+ break;
214
+ }
215
+ endIndex += 1;
216
+ }
217
+ if (endIndex >= text.length) return undefined;
218
+ const destination = text.slice(index + 1, endIndex).trim();
219
+ if (!isSupportedMarkdownLinkDestination(destination)) {
220
+ return undefined;
221
+ }
222
+ return { startIndex: index, endIndex, destination };
223
+ }
224
+
225
+ function replaceMarkdownLinkLike(
226
+ text: string,
227
+ options: {
228
+ renderInlineLink: (
229
+ link: ParsedMarkdownInlineLink,
230
+ supported: boolean,
231
+ ) => string;
232
+ renderAutolink: (link: ParsedMarkdownAutolink) => string;
233
+ },
234
+ ): string {
235
+ let result = "";
236
+ for (let index = 0; index < text.length; ) {
237
+ const inlineLink = parseMarkdownInlineLinkAt(text, index);
238
+ if (inlineLink) {
239
+ result += options.renderInlineLink(
240
+ inlineLink,
241
+ isSupportedMarkdownLinkDestination(inlineLink.destination),
242
+ );
243
+ index = inlineLink.endIndex + 1;
244
+ continue;
245
+ }
246
+ const autolink = parseMarkdownAutolinkAt(text, index);
247
+ if (autolink) {
248
+ result += options.renderAutolink(autolink);
249
+ index = autolink.endIndex + 1;
250
+ continue;
251
+ }
252
+ result += text[index] ?? "";
253
+ index += 1;
254
+ }
255
+ return result;
256
+ }
257
+
49
258
  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");
259
+ let result = replaceMarkdownLinkLike(text, {
260
+ renderInlineLink: (link, supported) => {
261
+ const plainLabel = stripInlineMarkdownToPlainText(link.label).trim();
262
+ if (plainLabel.length > 0) return plainLabel;
263
+ return supported ? link.destination : "";
264
+ },
265
+ renderAutolink: (link) => link.destination,
266
+ });
54
267
  result = result.replace(/`([^`\n]+)`/g, "$1");
55
268
  result = result.replace(/(\*\*\*|___)(.+?)\1/g, "$2");
56
269
  result = result.replace(/(\*\*|__)(.+?)\1/g, "$2");
@@ -146,6 +359,282 @@ function isMarkdownNumberedListMarker(marker: string): boolean {
146
359
  return /^\d+\.$/.test(marker);
147
360
  }
148
361
 
362
+ function matchMarkdownHeadingLine(line: string): RegExpMatchArray | null {
363
+ return line.match(/^(\s*)#{1,6}\s+(.+)$/);
364
+ }
365
+
366
+ function endsWithMarkdownHeadingLine(markdown: string): boolean {
367
+ const lines = markdown.split("\n");
368
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
369
+ const line = lines[index] ?? "";
370
+ if (line.trim().length === 0) continue;
371
+ return matchMarkdownHeadingLine(line) !== null;
372
+ }
373
+ return false;
374
+ }
375
+
376
+ function splitLeadingMarkdownBlankLines(markdown: string): {
377
+ blankLines: number;
378
+ remainingText: string;
379
+ } {
380
+ const lines = markdown.split("\n");
381
+ let start = 0;
382
+ while (start < lines.length && (lines[start] ?? "").trim().length === 0) {
383
+ start += 1;
384
+ }
385
+ return {
386
+ blankLines: start,
387
+ remainingText: lines.slice(start).join("\n"),
388
+ };
389
+ }
390
+
391
+ export type TelegramPreviewRenderStrategy = "plain" | "rich-stable-blocks";
392
+
393
+ export interface TelegramPreviewSnapshotStateLike {
394
+ pendingText: string;
395
+ lastSentText: string;
396
+ lastSentParseMode?: "HTML";
397
+ lastSentStrategy?: TelegramPreviewRenderStrategy;
398
+ }
399
+
400
+ export interface TelegramPreviewSnapshot extends TelegramRenderedChunk {
401
+ sourceText: string;
402
+ strategy: TelegramPreviewRenderStrategy;
403
+ }
404
+
405
+ export function buildTelegramPreviewFlushText(options: {
406
+ state: TelegramPreviewSnapshotStateLike;
407
+ maxMessageLength: number;
408
+ renderPreviewText: (markdown: string) => string;
409
+ }): string | undefined {
410
+ const rawText = options.state.pendingText.trim();
411
+ const previewText = options.renderPreviewText(rawText).trim();
412
+ if (!previewText || previewText === options.state.lastSentText) {
413
+ return undefined;
414
+ }
415
+ return previewText.length > options.maxMessageLength
416
+ ? previewText.slice(0, options.maxMessageLength)
417
+ : previewText;
418
+ }
419
+
420
+ function buildTelegramPlainPreviewSnapshot(options: {
421
+ sourceText: string;
422
+ state: TelegramPreviewSnapshotStateLike;
423
+ maxMessageLength: number;
424
+ renderPreviewText: (markdown: string) => string;
425
+ }): TelegramPreviewSnapshot | undefined {
426
+ const previewText = options.renderPreviewText(options.sourceText).trim();
427
+ if (!previewText) return undefined;
428
+ const truncatedPreviewText =
429
+ previewText.length > options.maxMessageLength
430
+ ? previewText.slice(0, options.maxMessageLength)
431
+ : previewText;
432
+ if (
433
+ truncatedPreviewText === options.state.lastSentText &&
434
+ options.state.lastSentStrategy === "plain"
435
+ ) {
436
+ return undefined;
437
+ }
438
+ return {
439
+ text: truncatedPreviewText,
440
+ sourceText: options.sourceText,
441
+ strategy: "plain",
442
+ };
443
+ }
444
+
445
+ interface TelegramStablePreviewSplit {
446
+ stableMarkdown: string;
447
+ unstableTail: string;
448
+ }
449
+
450
+ function splitTelegramStablePreviewMarkdown(
451
+ markdown: string,
452
+ ): TelegramStablePreviewSplit {
453
+ const normalized = normalizeMarkdownDocument(markdown);
454
+ if (normalized.length === 0) {
455
+ return { stableMarkdown: "", unstableTail: "" };
456
+ }
457
+ const lines = normalized.split("\n");
458
+ let index = 0;
459
+ let stableEndIndex = 0;
460
+ while (index < lines.length) {
461
+ while (index < lines.length && (lines[index] ?? "").trim().length === 0) {
462
+ index += 1;
463
+ }
464
+ if (index >= lines.length) break;
465
+ const blockStart = index;
466
+ const line = lines[index] ?? "";
467
+ const nextLine = lines[index + 1] ?? "";
468
+ const fence = parseMarkdownFence(line);
469
+ if (fence) {
470
+ index += 1;
471
+ while (
472
+ index < lines.length &&
473
+ !isMatchingMarkdownFence(lines[index] ?? "", fence)
474
+ ) {
475
+ index += 1;
476
+ }
477
+ if (index >= lines.length) {
478
+ return {
479
+ stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
480
+ unstableTail: lines.slice(stableEndIndex).join("\n"),
481
+ };
482
+ }
483
+ index += 1;
484
+ stableEndIndex = index;
485
+ continue;
486
+ }
487
+ if (line.includes("|") && isMarkdownTableSeparator(nextLine)) {
488
+ index += 2;
489
+ while (index < lines.length) {
490
+ const tableLine = lines[index] ?? "";
491
+ if (tableLine.trim().length === 0 || !tableLine.includes("|")) {
492
+ break;
493
+ }
494
+ index += 1;
495
+ }
496
+ if (index >= lines.length) {
497
+ return {
498
+ stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
499
+ unstableTail: lines.slice(stableEndIndex).join("\n"),
500
+ };
501
+ }
502
+ stableEndIndex = index;
503
+ continue;
504
+ }
505
+ if (canStartIndentedCodeBlock(lines, index)) {
506
+ while (index < lines.length) {
507
+ const rawLine = lines[index] ?? "";
508
+ if (rawLine.trim().length === 0) {
509
+ index += 1;
510
+ continue;
511
+ }
512
+ if (!isIndentedCodeLine(rawLine)) break;
513
+ index += 1;
514
+ }
515
+ if (index >= lines.length) {
516
+ return {
517
+ stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
518
+ unstableTail: lines.slice(stableEndIndex).join("\n"),
519
+ };
520
+ }
521
+ stableEndIndex = index;
522
+ continue;
523
+ }
524
+ if (/^\s*>/.test(line)) {
525
+ while (index < lines.length && /^\s*>/.test(lines[index] ?? "")) {
526
+ index += 1;
527
+ }
528
+ if (index >= lines.length) {
529
+ return {
530
+ stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
531
+ unstableTail: lines.slice(stableEndIndex).join("\n"),
532
+ };
533
+ }
534
+ stableEndIndex = index;
535
+ continue;
536
+ }
537
+ while (index < lines.length) {
538
+ const current = lines[index] ?? "";
539
+ const following = lines[index + 1] ?? "";
540
+ if (current.trim().length === 0) break;
541
+ if (
542
+ index !== blockStart &&
543
+ (isFencedCodeStart(current) ||
544
+ canStartIndentedCodeBlock(lines, index) ||
545
+ /^\s*>/.test(current) ||
546
+ (current.includes("|") && isMarkdownTableSeparator(following)))
547
+ ) {
548
+ break;
549
+ }
550
+ index += 1;
551
+ }
552
+ if (index >= lines.length) {
553
+ return {
554
+ stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
555
+ unstableTail: lines.slice(stableEndIndex).join("\n"),
556
+ };
557
+ }
558
+ stableEndIndex = index;
559
+ }
560
+ return {
561
+ stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
562
+ unstableTail: lines.slice(stableEndIndex).join("\n"),
563
+ };
564
+ }
565
+
566
+ export function buildTelegramPreviewSnapshot(options: {
567
+ state: TelegramPreviewSnapshotStateLike;
568
+ maxMessageLength: number;
569
+ renderPreviewText: (markdown: string) => string;
570
+ renderTelegramMessage: (
571
+ text: string,
572
+ options?: { mode?: TelegramRenderMode },
573
+ ) => TelegramRenderedChunk[];
574
+ }): TelegramPreviewSnapshot | undefined {
575
+ const sourceText = options.state.pendingText.trim();
576
+ if (!sourceText) return undefined;
577
+ const split = splitTelegramStablePreviewMarkdown(sourceText);
578
+ if (split.stableMarkdown.length === 0) {
579
+ return buildTelegramPlainPreviewSnapshot({
580
+ sourceText,
581
+ state: options.state,
582
+ maxMessageLength: options.maxMessageLength,
583
+ renderPreviewText: options.renderPreviewText,
584
+ });
585
+ }
586
+ const stableChunk = options.renderTelegramMessage(split.stableMarkdown, {
587
+ mode: "markdown",
588
+ })[0];
589
+ if (
590
+ !stableChunk ||
591
+ stableChunk.text.length === 0 ||
592
+ stableChunk.text.length > options.maxMessageLength
593
+ ) {
594
+ return buildTelegramPlainPreviewSnapshot({
595
+ sourceText,
596
+ state: options.state,
597
+ maxMessageLength: options.maxMessageLength,
598
+ renderPreviewText: options.renderPreviewText,
599
+ });
600
+ }
601
+ let previewText = stableChunk.text;
602
+ if (split.unstableTail.length > 0) {
603
+ const tail = splitLeadingMarkdownBlankLines(split.unstableTail);
604
+ const minimumBlankLinesBeforeTail = endsWithMarkdownHeadingLine(
605
+ split.stableMarkdown,
606
+ )
607
+ ? 1
608
+ : 0;
609
+ const blankLinesBeforeTail = Math.max(
610
+ tail.blankLines,
611
+ minimumBlankLinesBeforeTail,
612
+ );
613
+ const separator =
614
+ tail.remainingText.length > 0
615
+ ? "\n".repeat(blankLinesBeforeTail + 1)
616
+ : "";
617
+ const tailText = escapeHtml(tail.remainingText);
618
+ const candidate = `${previewText}${separator}${tailText}`;
619
+ if (candidate.length <= options.maxMessageLength) {
620
+ previewText = candidate;
621
+ }
622
+ }
623
+ if (
624
+ previewText === options.state.lastSentText &&
625
+ stableChunk.parseMode === options.state.lastSentParseMode &&
626
+ options.state.lastSentStrategy === "rich-stable-blocks"
627
+ ) {
628
+ return undefined;
629
+ }
630
+ return {
631
+ text: previewText,
632
+ parseMode: stableChunk.parseMode,
633
+ sourceText,
634
+ strategy: "rich-stable-blocks",
635
+ };
636
+ }
637
+
149
638
  export function renderMarkdownPreviewText(markdown: string): string {
150
639
  const normalized = normalizeMarkdownDocument(markdown);
151
640
  if (normalized.length === 0) return "";
@@ -161,7 +650,7 @@ export function renderMarkdownPreviewText(markdown: string): string {
161
650
  continue;
162
651
  }
163
652
  if (line.trim().length === 0) {
164
- if (output.at(-1) !== "") output.push("");
653
+ output.push("");
165
654
  continue;
166
655
  }
167
656
  output.push(line);
@@ -172,15 +661,15 @@ export function renderMarkdownPreviewText(markdown: string): string {
172
661
  continue;
173
662
  }
174
663
  if (line.trim().length === 0) {
175
- if (output.at(-1) !== "") output.push("");
664
+ output.push("");
176
665
  continue;
177
666
  }
178
667
  if (isMarkdownTableSeparator(line)) {
179
668
  continue;
180
669
  }
181
- const heading = line.match(/^\s*#{1,6}\s+(.+)$/);
670
+ const heading = matchMarkdownHeadingLine(line);
182
671
  if (heading) {
183
- output.push(stripInlineMarkdownToPlainText(heading[1] ?? ""));
672
+ output.push(stripInlineMarkdownToPlainText(heading[2] ?? ""));
184
673
  continue;
185
674
  }
186
675
  const task = line.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/);
@@ -252,26 +741,24 @@ function renderInlineMarkdown(text: string): string {
252
741
  tokens.push(html);
253
742
  return token;
254
743
  };
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>`);
744
+ let result = replaceMarkdownLinkLike(text, {
745
+ renderInlineLink: (link, supported) => {
746
+ const plainLabel = stripInlineMarkdownToPlainText(link.label).trim();
747
+ if (!supported) {
748
+ return plainLabel.length > 0 ? plainLabel : link.destination;
749
+ }
750
+ const renderedLabel =
751
+ plainLabel.length > 0 ? plainLabel : link.destination;
752
+ return makeToken(
753
+ `<a href="${escapeHtmlAttribute(link.destination)}">${escapeHtml(renderedLabel)}</a>`,
754
+ );
267
755
  },
268
- );
269
- result = result.replace(
270
- /<((?:https?:\/\/|mailto:)[^>]+)>/g,
271
- (_match, url: string) => {
272
- return makeToken(`<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>`);
756
+ renderAutolink: (link) => {
757
+ return makeToken(
758
+ `<a href="${escapeHtmlAttribute(link.destination)}">${escapeHtml(link.destination)}</a>`,
759
+ );
273
760
  },
274
- );
761
+ });
275
762
  result = result.replace(/`([^`\n]+)`/g, (_match, code: string) => {
276
763
  return makeToken(`<code>${escapeHtml(code)}</code>`);
277
764
  });
@@ -335,7 +822,7 @@ function renderMarkdownTextLines(block: string): string[] {
335
822
  if (line.trim().length === 0) continue;
336
823
  const pieces = splitPlainMarkdownLine(line);
337
824
  for (const piece of pieces) {
338
- const heading = piece.match(/^(\s*)#{1,6}\s+(.+)$/);
825
+ const heading = matchMarkdownHeadingLine(piece);
339
826
  if (heading) {
340
827
  rendered.push(
341
828
  `${buildListIndent(Math.floor((heading[1] ?? "").length / 2))}<b>${renderInlineMarkdown(heading[2] ?? "")}</b>`,
@@ -394,9 +881,14 @@ function renderMarkdownTextLines(block: string): string[] {
394
881
  return rendered;
395
882
  }
396
883
 
884
+ function sanitizeTelegramCodeLanguage(language: string): string {
885
+ return language.split(/\s+/)[0]?.replace(/[^A-Za-z0-9_+.-]/g, "") ?? "";
886
+ }
887
+
397
888
  function renderMarkdownCodeBlock(code: string, language?: string): string[] {
398
- const open = language
399
- ? `<pre><code class="language-${escapeHtml(language)}">`
889
+ const safeLanguage = language ? sanitizeTelegramCodeLanguage(language) : "";
890
+ const open = safeLanguage
891
+ ? `<pre><code class="language-${escapeHtmlAttribute(safeLanguage)}">`
400
892
  : "<pre><code>";
401
893
  const close = "</code></pre>";
402
894
  const maxContentLength = MAX_MESSAGE_LENGTH - open.length - close.length;
@@ -517,15 +1009,56 @@ function renderMarkdownQuoteBlock(lines: string[]): string[] {
517
1009
  });
518
1010
  }
519
1011
 
1012
+ interface TelegramRenderedBlockWithSpacing {
1013
+ text: string;
1014
+ blankLinesBefore: number;
1015
+ }
1016
+
520
1017
  function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
521
1018
  const normalized = normalizeMarkdownDocument(markdown);
522
1019
  if (normalized.length === 0) return [];
523
- const renderedBlocks: string[] = [];
1020
+ const renderedBlocks: TelegramRenderedBlockWithSpacing[] = [];
1021
+ let minimumBlankLinesBeforeNextBlock = 0;
1022
+ const pushRenderedBlocks = (
1023
+ blocks: string[],
1024
+ blankLinesBefore: number,
1025
+ ): void => {
1026
+ const effectiveBlankLinesBefore =
1027
+ renderedBlocks.length === 0
1028
+ ? blankLinesBefore
1029
+ : Math.max(blankLinesBefore, minimumBlankLinesBeforeNextBlock);
1030
+ for (const [blockIndex, block] of blocks.entries()) {
1031
+ renderedBlocks.push({
1032
+ text: block,
1033
+ blankLinesBefore: blockIndex === 0 ? effectiveBlankLinesBefore : 0,
1034
+ });
1035
+ }
1036
+ minimumBlankLinesBeforeNextBlock = 0;
1037
+ };
524
1038
  const lines = normalized.split("\n");
525
1039
  let index = 0;
1040
+ let pendingBlankLines = 0;
526
1041
  while (index < lines.length) {
527
1042
  const line = lines[index] ?? "";
528
1043
  const nextLine = lines[index + 1] ?? "";
1044
+ if (line.trim().length === 0) {
1045
+ pendingBlankLines += 1;
1046
+ index += 1;
1047
+ continue;
1048
+ }
1049
+ const heading = matchMarkdownHeadingLine(line);
1050
+ if (heading) {
1051
+ pushRenderedBlocks(
1052
+ renderMarkdownTextBlock(line),
1053
+ renderedBlocks.length === 0
1054
+ ? pendingBlankLines
1055
+ : Math.max(pendingBlankLines, 1),
1056
+ );
1057
+ pendingBlankLines = 0;
1058
+ minimumBlankLinesBeforeNextBlock = 1;
1059
+ index += 1;
1060
+ continue;
1061
+ }
529
1062
  const fence = parseMarkdownFence(line);
530
1063
  if (fence) {
531
1064
  index += 1;
@@ -540,16 +1073,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
540
1073
  if (index < lines.length) {
541
1074
  index += 1;
542
1075
  }
543
- renderedBlocks.push(
544
- ...renderMarkdownCodeBlock(codeLines.join("\n"), fence.info),
1076
+ pushRenderedBlocks(
1077
+ renderMarkdownCodeBlock(codeLines.join("\n"), fence.info),
1078
+ pendingBlankLines,
545
1079
  );
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;
1080
+ pendingBlankLines = 0;
553
1081
  continue;
554
1082
  }
555
1083
  if (line.includes("|") && isMarkdownTableSeparator(nextLine)) {
@@ -563,7 +1091,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
563
1091
  tableLines.push(tableLine);
564
1092
  index += 1;
565
1093
  }
566
- renderedBlocks.push(...renderMarkdownTableBlock(tableLines));
1094
+ pushRenderedBlocks(
1095
+ renderMarkdownTableBlock(tableLines),
1096
+ pendingBlankLines,
1097
+ );
1098
+ pendingBlankLines = 0;
567
1099
  continue;
568
1100
  }
569
1101
  if (canStartIndentedCodeBlock(lines, index)) {
@@ -579,7 +1111,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
579
1111
  codeLines.push(stripIndentedCodePrefix(rawLine));
580
1112
  index += 1;
581
1113
  }
582
- renderedBlocks.push(...renderMarkdownCodeBlock(codeLines.join("\n")));
1114
+ pushRenderedBlocks(
1115
+ renderMarkdownCodeBlock(codeLines.join("\n")),
1116
+ pendingBlankLines,
1117
+ );
1118
+ pendingBlankLines = 0;
583
1119
  continue;
584
1120
  }
585
1121
  if (/^\s*>/.test(line)) {
@@ -588,7 +1124,11 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
588
1124
  quoteLines.push(lines[index] ?? "");
589
1125
  index += 1;
590
1126
  }
591
- renderedBlocks.push(...renderMarkdownQuoteBlock(quoteLines));
1127
+ pushRenderedBlocks(
1128
+ renderMarkdownQuoteBlock(quoteLines),
1129
+ pendingBlankLines,
1130
+ );
1131
+ pendingBlankLines = 0;
592
1132
  continue;
593
1133
  }
594
1134
  const textLines: string[] = [];
@@ -606,12 +1146,18 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
606
1146
  textLines.push(current);
607
1147
  index += 1;
608
1148
  }
609
- renderedBlocks.push(...renderMarkdownTextBlock(textLines.join("\n")));
1149
+ pushRenderedBlocks(
1150
+ renderMarkdownTextBlock(textLines.join("\n")),
1151
+ pendingBlankLines,
1152
+ );
1153
+ pendingBlankLines = 0;
610
1154
  }
611
1155
  const chunks: string[] = [];
612
1156
  let current = "";
613
1157
  for (const block of renderedBlocks) {
614
- const candidate = current.length === 0 ? block : `${current}\n\n${block}`;
1158
+ const separator = "\n".repeat(block.blankLinesBefore + 1);
1159
+ const candidate =
1160
+ current.length === 0 ? block.text : `${current}${separator}${block.text}`;
615
1161
  if (candidate.length <= MAX_MESSAGE_LENGTH) {
616
1162
  current = candidate;
617
1163
  continue;
@@ -620,12 +1166,12 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
620
1166
  chunks.push(current);
621
1167
  current = "";
622
1168
  }
623
- if (block.length <= MAX_MESSAGE_LENGTH) {
624
- current = block;
1169
+ if (block.text.length <= MAX_MESSAGE_LENGTH) {
1170
+ current = block.text;
625
1171
  continue;
626
1172
  }
627
- for (let i = 0; i < block.length; i += MAX_MESSAGE_LENGTH) {
628
- chunks.push(block.slice(i, i + MAX_MESSAGE_LENGTH));
1173
+ for (let i = 0; i < block.text.length; i += MAX_MESSAGE_LENGTH) {
1174
+ chunks.push(block.text.slice(i, i + MAX_MESSAGE_LENGTH));
629
1175
  }
630
1176
  }
631
1177
  if (current.length > 0) {
@@ -699,6 +1245,94 @@ function chunkParagraphs(text: string): string[] {
699
1245
  return chunks;
700
1246
  }
701
1247
 
1248
+ interface OpenHtmlTag {
1249
+ name: string;
1250
+ openTag: string;
1251
+ }
1252
+
1253
+ const TELEGRAM_VOID_HTML_TAGS = new Set(["br", "hr"]);
1254
+
1255
+ function getHtmlTagName(tag: string): string | undefined {
1256
+ return tag.match(/^<\/?\s*([a-zA-Z][\w-]*)/)?.[1]?.toLowerCase();
1257
+ }
1258
+
1259
+ function isHtmlClosingTag(tag: string): boolean {
1260
+ return /^<\//.test(tag);
1261
+ }
1262
+
1263
+ function isHtmlSelfClosingTag(tag: string): boolean {
1264
+ return /\/\s*>$/.test(tag);
1265
+ }
1266
+
1267
+ function getHtmlClosingTags(openTags: OpenHtmlTag[]): string {
1268
+ return [...openTags]
1269
+ .reverse()
1270
+ .map((tag) => `</${tag.name}>`)
1271
+ .join("");
1272
+ }
1273
+
1274
+ function getHtmlOpeningTags(openTags: OpenHtmlTag[]): string {
1275
+ return openTags.map((tag) => tag.openTag).join("");
1276
+ }
1277
+
1278
+ function updateOpenHtmlTags(tag: string, openTags: OpenHtmlTag[]): void {
1279
+ const name = getHtmlTagName(tag);
1280
+ if (!name || TELEGRAM_VOID_HTML_TAGS.has(name)) return;
1281
+ if (isHtmlClosingTag(tag)) {
1282
+ const index = openTags.map((openTag) => openTag.name).lastIndexOf(name);
1283
+ if (index !== -1) openTags.splice(index, 1);
1284
+ return;
1285
+ }
1286
+ if (isHtmlSelfClosingTag(tag)) return;
1287
+ openTags.push({ name, openTag: tag });
1288
+ }
1289
+
1290
+ function chunkHtmlPreservingTags(html: string): string[] {
1291
+ if (html.length <= MAX_MESSAGE_LENGTH) return [html];
1292
+ const chunks: string[] = [];
1293
+ const openTags: OpenHtmlTag[] = [];
1294
+ const tagPattern = /<\/?[a-zA-Z][^>]*>/g;
1295
+ let current = "";
1296
+ let index = 0;
1297
+ const flushCurrent = (): void => {
1298
+ if (current.length === 0) return;
1299
+ chunks.push(`${current}${getHtmlClosingTags(openTags)}`);
1300
+ current = getHtmlOpeningTags(openTags);
1301
+ };
1302
+ const appendText = (text: string): void => {
1303
+ let remaining = text;
1304
+ while (remaining.length > 0) {
1305
+ const closingTags = getHtmlClosingTags(openTags);
1306
+ const available =
1307
+ MAX_MESSAGE_LENGTH - current.length - closingTags.length;
1308
+ if (available <= 0) {
1309
+ flushCurrent();
1310
+ continue;
1311
+ }
1312
+ const slice = remaining.slice(0, available);
1313
+ current += slice;
1314
+ remaining = remaining.slice(slice.length);
1315
+ if (remaining.length > 0) flushCurrent();
1316
+ }
1317
+ };
1318
+ const appendTag = (tag: string): void => {
1319
+ const closingTags = getHtmlClosingTags(openTags);
1320
+ if (current.length + tag.length + closingTags.length > MAX_MESSAGE_LENGTH) {
1321
+ flushCurrent();
1322
+ }
1323
+ current += tag;
1324
+ updateOpenHtmlTags(tag, openTags);
1325
+ };
1326
+ for (const match of html.matchAll(tagPattern)) {
1327
+ appendText(html.slice(index, match.index));
1328
+ appendTag(match[0]);
1329
+ index = match.index + match[0].length;
1330
+ }
1331
+ appendText(html.slice(index));
1332
+ if (current.length > 0) chunks.push(current);
1333
+ return chunks;
1334
+ }
1335
+
702
1336
  export function renderTelegramMessage(
703
1337
  text: string,
704
1338
  options?: { mode?: TelegramRenderMode },
@@ -708,7 +1342,10 @@ export function renderTelegramMessage(
708
1342
  return chunkParagraphs(text).map((chunk) => ({ text: chunk }));
709
1343
  }
710
1344
  if (mode === "html") {
711
- return [{ text, parseMode: "HTML" }];
1345
+ return chunkHtmlPreservingTags(text).map((chunk) => ({
1346
+ text: chunk,
1347
+ parseMode: "HTML",
1348
+ }));
712
1349
  }
713
1350
  return renderMarkdownToTelegramHtmlChunks(text).map((chunk) => ({
714
1351
  text: chunk,