@llblab/pi-telegram 0.2.9 → 0.3.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/lib/rendering.ts CHANGED
@@ -5,15 +5,118 @@
5
5
 
6
6
  export const MAX_MESSAGE_LENGTH = 4096;
7
7
 
8
- // --- Escaping ---
8
+ const TELEGRAM_TABLE_GRAPHEME_SEGMENTER =
9
+ typeof Intl.Segmenter === "function"
10
+ ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
11
+ : undefined;
12
+ const TELEGRAM_TABLE_EMOJI_GRAPHEME_PATTERN =
13
+ /\p{Extended_Pictographic}|\p{Emoji_Presentation}|\p{Regional_Indicator}/u;
9
14
 
10
- function escapeHtml(text: string): string {
15
+ // --- HTML Helpers ---
16
+
17
+ interface OpenHtmlTag {
18
+ name: string;
19
+ openTag: string;
20
+ }
21
+
22
+ const TELEGRAM_VOID_HTML_TAGS = new Set(["br", "hr"]);
23
+
24
+ export function escapeHtml(text: string): string {
11
25
  return text
12
26
  .replace(/&/g, "&")
13
27
  .replace(/</g, "&lt;")
14
28
  .replace(/>/g, "&gt;");
15
29
  }
16
30
 
31
+ export function escapeHtmlAttribute(text: string): string {
32
+ return escapeHtml(text).replace(/"/g, "&quot;").replace(/'/g, "&#39;");
33
+ }
34
+
35
+ function getHtmlTagName(tag: string): string | undefined {
36
+ return tag.match(/^<\/?\s*([a-zA-Z][\w-]*)/)?.[1]?.toLowerCase();
37
+ }
38
+
39
+ function isHtmlClosingTag(tag: string): boolean {
40
+ return /^<\//.test(tag);
41
+ }
42
+
43
+ function isHtmlSelfClosingTag(tag: string): boolean {
44
+ return /\/\s*>$/.test(tag);
45
+ }
46
+
47
+ function getHtmlClosingTags(openTags: OpenHtmlTag[]): string {
48
+ return [...openTags]
49
+ .reverse()
50
+ .map((tag) => `</${tag.name}>`)
51
+ .join("");
52
+ }
53
+
54
+ function getHtmlOpeningTags(openTags: OpenHtmlTag[]): string {
55
+ return openTags.map((tag) => tag.openTag).join("");
56
+ }
57
+
58
+ function updateOpenHtmlTags(tag: string, openTags: OpenHtmlTag[]): void {
59
+ const name = getHtmlTagName(tag);
60
+ if (!name || TELEGRAM_VOID_HTML_TAGS.has(name)) return;
61
+ if (isHtmlClosingTag(tag)) {
62
+ const index = openTags.map((openTag) => openTag.name).lastIndexOf(name);
63
+ if (index !== -1) openTags.splice(index, 1);
64
+ return;
65
+ }
66
+ if (isHtmlSelfClosingTag(tag)) return;
67
+ openTags.push({ name, openTag: tag });
68
+ }
69
+
70
+ export function chunkHtmlPreservingTags(
71
+ html: string,
72
+ maxLength: number,
73
+ ): string[] {
74
+ if (html.length <= maxLength) return [html];
75
+ const chunks: string[] = [];
76
+ const openTags: OpenHtmlTag[] = [];
77
+ const tagPattern = /<\/?[a-zA-Z][^>]*>/g;
78
+ let current = "";
79
+ let index = 0;
80
+ const flushCurrent = (): void => {
81
+ if (current.length === 0) return;
82
+ chunks.push(`${current}${getHtmlClosingTags(openTags)}`);
83
+ current = getHtmlOpeningTags(openTags);
84
+ };
85
+ const appendText = (text: string): void => {
86
+ let remaining = text;
87
+ while (remaining.length > 0) {
88
+ const closingTags = getHtmlClosingTags(openTags);
89
+ const available = maxLength - current.length - closingTags.length;
90
+ if (available <= 0) {
91
+ flushCurrent();
92
+ continue;
93
+ }
94
+ const slice = remaining.slice(0, available);
95
+ current += slice;
96
+ remaining = remaining.slice(slice.length);
97
+ if (remaining.length > 0) flushCurrent();
98
+ }
99
+ };
100
+ const appendTag = (tag: string): void => {
101
+ const closingTags = isHtmlClosingTag(tag)
102
+ ? ""
103
+ : getHtmlClosingTags(openTags);
104
+ if (current.length + tag.length + closingTags.length > maxLength) {
105
+ flushCurrent();
106
+ }
107
+ current += tag;
108
+ updateOpenHtmlTags(tag, openTags);
109
+ };
110
+ for (const match of html.matchAll(tagPattern)) {
111
+ appendText(html.slice(index, match.index));
112
+ appendTag(match[0]);
113
+ index = match.index + match[0].length;
114
+ }
115
+ appendText(html.slice(index));
116
+ if (current.length > 0) chunks.push(current);
117
+ return chunks;
118
+ }
119
+
17
120
  // --- Plain Preview Rendering ---
18
121
 
19
122
  function splitPlainMarkdownLine(line: string, maxLength = 1500): string[] {
@@ -218,7 +321,7 @@ function parseMarkdownAutolinkAt(
218
321
  return { startIndex: index, endIndex, destination };
219
322
  }
220
323
 
221
- function replaceMarkdownLinkLike(
324
+ function replaceMarkdownLink(
222
325
  text: string,
223
326
  options: {
224
327
  renderInlineLink: (
@@ -252,7 +355,7 @@ function replaceMarkdownLinkLike(
252
355
  }
253
356
 
254
357
  function stripInlineMarkdownToPlainText(text: string): string {
255
- let result = replaceMarkdownLinkLike(text, {
358
+ let result = replaceMarkdownLink(text, {
256
359
  renderInlineLink: (link, supported) => {
257
360
  const plainLabel = stripInlineMarkdownToPlainText(link.label).trim();
258
361
  if (plainLabel.length > 0) return plainLabel;
@@ -386,7 +489,7 @@ function splitLeadingMarkdownBlankLines(markdown: string): {
386
489
 
387
490
  export type TelegramPreviewRenderStrategy = "plain" | "rich-stable-blocks";
388
491
 
389
- export interface TelegramPreviewSnapshotStateLike {
492
+ export interface TelegramPreviewSnapshotState {
390
493
  pendingText: string;
391
494
  lastSentText: string;
392
495
  lastSentParseMode?: "HTML";
@@ -399,7 +502,7 @@ export interface TelegramPreviewSnapshot extends TelegramRenderedChunk {
399
502
  }
400
503
 
401
504
  export function buildTelegramPreviewFlushText(options: {
402
- state: TelegramPreviewSnapshotStateLike;
505
+ state: TelegramPreviewSnapshotState;
403
506
  maxMessageLength: number;
404
507
  renderPreviewText: (markdown: string) => string;
405
508
  }): string | undefined {
@@ -415,7 +518,7 @@ export function buildTelegramPreviewFlushText(options: {
415
518
 
416
519
  function buildTelegramPlainPreviewSnapshot(options: {
417
520
  sourceText: string;
418
- state: TelegramPreviewSnapshotStateLike;
521
+ state: TelegramPreviewSnapshotState;
419
522
  maxMessageLength: number;
420
523
  renderPreviewText: (markdown: string) => string;
421
524
  }): TelegramPreviewSnapshot | undefined {
@@ -443,13 +546,44 @@ interface TelegramStablePreviewSplit {
443
546
  unstableTail: string;
444
547
  }
445
548
 
549
+ function buildTelegramStablePreviewSplit(
550
+ lines: string[],
551
+ stableEndIndex: number,
552
+ ): TelegramStablePreviewSplit {
553
+ return {
554
+ stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
555
+ unstableTail: lines.slice(stableEndIndex).join("\n"),
556
+ };
557
+ }
558
+
559
+ function collectTelegramStablePreviewTextBlockLines(
560
+ lines: string[],
561
+ index: number,
562
+ ): { nextIndex: number } {
563
+ let nextIndex = index;
564
+ while (nextIndex < lines.length) {
565
+ const current = lines[nextIndex] ?? "";
566
+ const following = lines[nextIndex + 1] ?? "";
567
+ if (current.trim().length === 0) break;
568
+ if (
569
+ nextIndex !== index &&
570
+ (isFencedCodeStart(current) ||
571
+ canStartIndentedCodeBlock(lines, nextIndex) ||
572
+ /^\s*>/.test(current) ||
573
+ (current.includes("|") && isMarkdownTableSeparator(following)))
574
+ ) {
575
+ break;
576
+ }
577
+ nextIndex += 1;
578
+ }
579
+ return { nextIndex };
580
+ }
581
+
446
582
  function splitTelegramStablePreviewMarkdown(
447
583
  markdown: string,
448
584
  ): TelegramStablePreviewSplit {
449
585
  const normalized = normalizeMarkdownDocument(markdown);
450
- if (normalized.length === 0) {
451
- return { stableMarkdown: "", unstableTail: "" };
452
- }
586
+ if (normalized.length === 0) return { stableMarkdown: "", unstableTail: "" };
453
587
  const lines = normalized.split("\n");
454
588
  let index = 0;
455
589
  let stableEndIndex = 0;
@@ -463,104 +597,108 @@ function splitTelegramStablePreviewMarkdown(
463
597
  const nextLine = lines[index + 1] ?? "";
464
598
  const fence = parseMarkdownFence(line);
465
599
  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
- };
600
+ const block = collectFencedMarkdownCodeLines(lines, index, fence);
601
+ if (!block.closed) {
602
+ return buildTelegramStablePreviewSplit(lines, stableEndIndex);
478
603
  }
479
- index += 1;
604
+ index = block.nextIndex;
480
605
  stableEndIndex = index;
481
606
  continue;
482
607
  }
483
608
  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
- }
609
+ const block = collectMarkdownTableBlockLines(lines, index);
610
+ index = block.nextIndex;
492
611
  if (index >= lines.length) {
493
- return {
494
- stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
495
- unstableTail: lines.slice(stableEndIndex).join("\n"),
496
- };
612
+ return buildTelegramStablePreviewSplit(lines, stableEndIndex);
497
613
  }
498
614
  stableEndIndex = index;
499
615
  continue;
500
616
  }
501
617
  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
- }
618
+ const block = collectIndentedMarkdownCodeLines(lines, index);
619
+ index = block.nextIndex;
511
620
  if (index >= lines.length) {
512
- return {
513
- stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
514
- unstableTail: lines.slice(stableEndIndex).join("\n"),
515
- };
621
+ return buildTelegramStablePreviewSplit(lines, stableEndIndex);
516
622
  }
517
623
  stableEndIndex = index;
518
624
  continue;
519
625
  }
520
626
  if (/^\s*>/.test(line)) {
521
- while (index < lines.length && /^\s*>/.test(lines[index] ?? "")) {
522
- index += 1;
523
- }
627
+ const block = collectMarkdownQuoteBlockLines(lines, index);
628
+ index = block.nextIndex;
524
629
  if (index >= lines.length) {
525
- return {
526
- stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
527
- unstableTail: lines.slice(stableEndIndex).join("\n"),
528
- };
630
+ return buildTelegramStablePreviewSplit(lines, stableEndIndex);
529
631
  }
530
632
  stableEndIndex = index;
531
633
  continue;
532
634
  }
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
- }
635
+ const block = collectTelegramStablePreviewTextBlockLines(lines, blockStart);
636
+ index = block.nextIndex;
548
637
  if (index >= lines.length) {
549
- return {
550
- stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
551
- unstableTail: lines.slice(stableEndIndex).join("\n"),
552
- };
638
+ return buildTelegramStablePreviewSplit(lines, stableEndIndex);
553
639
  }
554
640
  stableEndIndex = index;
555
641
  }
556
- return {
557
- stableMarkdown: lines.slice(0, stableEndIndex).join("\n"),
558
- unstableTail: lines.slice(stableEndIndex).join("\n"),
559
- };
642
+ return buildTelegramStablePreviewSplit(lines, stableEndIndex);
643
+ }
644
+
645
+ function renderTelegramStablePreviewChunk(options: {
646
+ stableMarkdown: string;
647
+ maxMessageLength: number;
648
+ renderTelegramMessage: (
649
+ text: string,
650
+ options?: { mode?: TelegramRenderMode },
651
+ ) => TelegramRenderedChunk[];
652
+ }): TelegramRenderedChunk | undefined {
653
+ const stableChunk = options.renderTelegramMessage(options.stableMarkdown, {
654
+ mode: "markdown",
655
+ })[0];
656
+ if (!stableChunk || stableChunk.text.length === 0) return undefined;
657
+ if (stableChunk.text.length > options.maxMessageLength) return undefined;
658
+ return stableChunk;
659
+ }
660
+
661
+ function appendTelegramUnstablePreviewTail(options: {
662
+ previewText: string;
663
+ stableMarkdown: string;
664
+ unstableTail: string;
665
+ maxMessageLength: number;
666
+ }): string {
667
+ if (options.unstableTail.length === 0) return options.previewText;
668
+ const tail = splitLeadingMarkdownBlankLines(options.unstableTail);
669
+ const minimumBlankLinesBeforeTail = endsWithMarkdownHeadingLine(
670
+ options.stableMarkdown,
671
+ )
672
+ ? 1
673
+ : 0;
674
+ const blankLinesBeforeTail = Math.max(
675
+ tail.blankLines,
676
+ minimumBlankLinesBeforeTail,
677
+ );
678
+ const separator =
679
+ tail.remainingText.length > 0 ? "\n".repeat(blankLinesBeforeTail + 1) : "";
680
+ const tailText = escapeHtml(tail.remainingText);
681
+ const candidate = `${options.previewText}${separator}${tailText}`;
682
+ return candidate.length <= options.maxMessageLength
683
+ ? candidate
684
+ : options.previewText;
685
+ }
686
+
687
+ function isTelegramPreviewSnapshotUnchanged(options: {
688
+ text: string;
689
+ parseMode?: "HTML";
690
+ state: TelegramPreviewSnapshotState;
691
+ strategy: TelegramPreviewRenderStrategy;
692
+ }): boolean {
693
+ return (
694
+ options.text === options.state.lastSentText &&
695
+ options.parseMode === options.state.lastSentParseMode &&
696
+ options.strategy === options.state.lastSentStrategy
697
+ );
560
698
  }
561
699
 
562
700
  export function buildTelegramPreviewSnapshot(options: {
563
- state: TelegramPreviewSnapshotStateLike;
701
+ state: TelegramPreviewSnapshotState;
564
702
  maxMessageLength: number;
565
703
  renderPreviewText: (markdown: string) => string;
566
704
  renderTelegramMessage: (
@@ -579,14 +717,12 @@ export function buildTelegramPreviewSnapshot(options: {
579
717
  renderPreviewText: options.renderPreviewText,
580
718
  });
581
719
  }
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
- ) {
720
+ const stableChunk = renderTelegramStablePreviewChunk({
721
+ stableMarkdown: split.stableMarkdown,
722
+ maxMessageLength: options.maxMessageLength,
723
+ renderTelegramMessage: options.renderTelegramMessage,
724
+ });
725
+ if (!stableChunk) {
590
726
  return buildTelegramPlainPreviewSnapshot({
591
727
  sourceText,
592
728
  state: options.state,
@@ -594,32 +730,19 @@ export function buildTelegramPreviewSnapshot(options: {
594
730
  renderPreviewText: options.renderPreviewText,
595
731
  });
596
732
  }
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
- }
733
+ const previewText = appendTelegramUnstablePreviewTail({
734
+ previewText: stableChunk.text,
735
+ stableMarkdown: split.stableMarkdown,
736
+ unstableTail: split.unstableTail,
737
+ maxMessageLength: options.maxMessageLength,
738
+ });
619
739
  if (
620
- previewText === options.state.lastSentText &&
621
- stableChunk.parseMode === options.state.lastSentParseMode &&
622
- options.state.lastSentStrategy === "rich-stable-blocks"
740
+ isTelegramPreviewSnapshotUnchanged({
741
+ text: previewText,
742
+ parseMode: stableChunk.parseMode,
743
+ state: options.state,
744
+ strategy: "rich-stable-blocks",
745
+ })
623
746
  ) {
624
747
  return undefined;
625
748
  }
@@ -730,36 +853,55 @@ function renderDelimitedInlineStyle(
730
853
  );
731
854
  }
732
855
 
733
- function renderInlineMarkdown(text: string): string {
734
- const tokens: string[] = [];
735
- const makeToken = (html: string): string => {
736
- const token = `\uE000${tokens.length}\uE001`;
737
- tokens.push(html);
738
- return token;
739
- };
740
- let result = replaceMarkdownLinkLike(text, {
856
+ interface InlineMarkdownTokenState {
857
+ tokens: string[];
858
+ }
859
+
860
+ function makeInlineMarkdownToken(
861
+ state: InlineMarkdownTokenState,
862
+ html: string,
863
+ ): string {
864
+ const token = `\uE000${state.tokens.length}\uE001`;
865
+ state.tokens.push(html);
866
+ return token;
867
+ }
868
+
869
+ function stashInlineMarkdownLinks(
870
+ text: string,
871
+ state: InlineMarkdownTokenState,
872
+ ): string {
873
+ return replaceMarkdownLink(text, {
741
874
  renderInlineLink: (link, supported) => {
742
875
  const plainLabel = stripInlineMarkdownToPlainText(link.label).trim();
743
- if (!supported) {
876
+ if (!supported)
744
877
  return plainLabel.length > 0 ? plainLabel : link.destination;
745
- }
746
878
  const renderedLabel =
747
879
  plainLabel.length > 0 ? plainLabel : link.destination;
748
- return makeToken(
749
- `<a href="${escapeHtml(link.destination)}">${escapeHtml(renderedLabel)}</a>`,
880
+ return makeInlineMarkdownToken(
881
+ state,
882
+ `<a href="${escapeHtmlAttribute(link.destination)}">${escapeHtml(renderedLabel)}</a>`,
750
883
  );
751
884
  },
752
885
  renderAutolink: (link) => {
753
- return makeToken(
754
- `<a href="${escapeHtml(link.destination)}">${escapeHtml(link.destination)}</a>`,
886
+ return makeInlineMarkdownToken(
887
+ state,
888
+ `<a href="${escapeHtmlAttribute(link.destination)}">${escapeHtml(link.destination)}</a>`,
755
889
  );
756
890
  },
757
891
  });
758
- result = result.replace(/`([^`\n]+)`/g, (_match, code: string) => {
759
- return makeToken(`<code>${escapeHtml(code)}</code>`);
892
+ }
893
+
894
+ function stashInlineMarkdownCodeSpans(
895
+ text: string,
896
+ state: InlineMarkdownTokenState,
897
+ ): string {
898
+ return text.replace(/`([^`\n]+)`/g, (_match, code: string) => {
899
+ return makeInlineMarkdownToken(state, `<code>${escapeHtml(code)}</code>`);
760
900
  });
761
- result = escapeHtml(result);
762
- result = renderDelimitedInlineStyle(result, "***", (content) => {
901
+ }
902
+
903
+ function applyInlineMarkdownStyles(text: string): string {
904
+ let result = renderDelimitedInlineStyle(text, "***", (content) => {
763
905
  return `<b><i>${content}</i></b>`;
764
906
  });
765
907
  result = renderDelimitedInlineStyle(result, "___", (content) => {
@@ -777,16 +919,31 @@ function renderInlineMarkdown(text: string): string {
777
919
  result = renderDelimitedInlineStyle(result, "*", (content) => {
778
920
  return `<i>${content}</i>`;
779
921
  });
780
- result = renderDelimitedInlineStyle(result, "_", (content) => {
922
+ return renderDelimitedInlineStyle(result, "_", (content) => {
781
923
  return `<i>${content}</i>`;
782
924
  });
783
- result = result.replace(/\\([\\`*_{}\[\]()#+\-.!>~|])/g, "$1");
784
- return result.replace(
925
+ }
926
+
927
+ function restoreInlineMarkdownTokens(
928
+ text: string,
929
+ state: InlineMarkdownTokenState,
930
+ ): string {
931
+ return text.replace(
785
932
  /\uE000(\d+)\uE001/g,
786
- (_match, index: string) => tokens[Number(index)] ?? "",
933
+ (_match, index: string) => state.tokens[Number(index)] ?? "",
787
934
  );
788
935
  }
789
936
 
937
+ function renderInlineMarkdown(text: string): string {
938
+ const tokenState: InlineMarkdownTokenState = { tokens: [] };
939
+ let result = stashInlineMarkdownLinks(text, tokenState);
940
+ result = stashInlineMarkdownCodeSpans(result, tokenState);
941
+ result = escapeHtml(result);
942
+ result = applyInlineMarkdownStyles(result);
943
+ result = result.replace(/\\([\\`*_{}\[\]()#+\-.!>~|])/g, "$1");
944
+ return restoreInlineMarkdownTokens(result, tokenState);
945
+ }
946
+
790
947
  function buildListIndent(level: number): string {
791
948
  return "\u00A0".repeat(Math.max(0, level) * 2);
792
949
  }
@@ -811,75 +968,61 @@ function parseMarkdownQuoteLine(
811
968
  };
812
969
  }
813
970
 
971
+ function renderMarkdownTextPiece(piece: string): string {
972
+ const heading = matchMarkdownHeadingLine(piece);
973
+ if (heading) {
974
+ const indent = buildListIndent(Math.floor((heading[1] ?? "").length / 2));
975
+ return `${indent}<b>${renderInlineMarkdown(heading[2] ?? "")}</b>`;
976
+ }
977
+ const task = piece.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/);
978
+ if (task) {
979
+ const indent = buildListIndent(Math.floor((task[1] ?? "").length / 2));
980
+ const listMarker = task[2] ?? "-";
981
+ const checkboxMarker =
982
+ (task[3] ?? " ").toLowerCase() === "x" ? "[x]" : "[ ]";
983
+ const taskPrefix = isMarkdownNumberedListMarker(listMarker)
984
+ ? `<code>${listMarker}</code> <code>${checkboxMarker}</code>`
985
+ : `<code>${checkboxMarker}</code>`;
986
+ return `${indent}${taskPrefix} ${renderInlineMarkdown(task[4] ?? "")}`;
987
+ }
988
+ const bullet = piece.match(/^(\s*)[-*+]\s+(.+)$/);
989
+ if (bullet) {
990
+ const indent = buildListIndent(Math.floor((bullet[1] ?? "").length / 2));
991
+ return `${indent}<code>-</code> ${renderInlineMarkdown(bullet[2] ?? "")}`;
992
+ }
993
+ const numbered = piece.match(/^(\s*)(\d+)\.\s+(.+)$/);
994
+ if (numbered) {
995
+ const indent = buildListIndent(Math.floor((numbered[1] ?? "").length / 2));
996
+ return `${indent}<code>${numbered[2]}.</code> ${renderInlineMarkdown(numbered[3] ?? "")}`;
997
+ }
998
+ const quote = piece.match(/^>\s?(.+)$/);
999
+ if (quote) {
1000
+ return `<blockquote>${renderInlineMarkdown(quote[1] ?? "")}</blockquote>`;
1001
+ }
1002
+ if (/^([-*_]\s*){3,}$/.test(piece.trim())) return "────────────";
1003
+ return renderInlineMarkdown(piece);
1004
+ }
1005
+
814
1006
  function renderMarkdownTextLines(block: string): string[] {
815
1007
  const rendered: string[] = [];
816
1008
  const lines = block.split("\n");
817
1009
  for (const line of lines) {
818
1010
  if (line.trim().length === 0) continue;
819
- const pieces = splitPlainMarkdownLine(line);
820
- for (const piece of pieces) {
821
- const heading = matchMarkdownHeadingLine(piece);
822
- if (heading) {
823
- rendered.push(
824
- `${buildListIndent(Math.floor((heading[1] ?? "").length / 2))}<b>${renderInlineMarkdown(heading[2] ?? "")}</b>`,
825
- );
826
- continue;
827
- }
828
- const task = piece.match(/^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.+)$/);
829
- if (task) {
830
- const indent = buildListIndent(Math.floor((task[1] ?? "").length / 2));
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>`;
837
- rendered.push(
838
- `${indent}${taskPrefix} ${renderInlineMarkdown(task[4] ?? "")}`,
839
- );
840
- continue;
841
- }
842
- const bullet = piece.match(/^(\s*)[-*+]\s+(.+)$/);
843
- if (bullet) {
844
- const indent = buildListIndent(
845
- Math.floor((bullet[1] ?? "").length / 2),
846
- );
847
- rendered.push(
848
- `${indent}<code>-</code> ${renderInlineMarkdown(bullet[2] ?? "")}`,
849
- );
850
- continue;
851
- }
852
- const numbered = piece.match(/^(\s*)(\d+)\.\s+(.+)$/);
853
- if (numbered) {
854
- const indent = buildListIndent(
855
- Math.floor((numbered[1] ?? "").length / 2),
856
- );
857
- rendered.push(
858
- `${indent}<code>${numbered[2]}.</code> ${renderInlineMarkdown(numbered[3] ?? "")}`,
859
- );
860
- continue;
861
- }
862
- const quote = piece.match(/^>\s?(.+)$/);
863
- if (quote) {
864
- rendered.push(
865
- `<blockquote>${renderInlineMarkdown(quote[1] ?? "")}</blockquote>`,
866
- );
867
- continue;
868
- }
869
- const trimmed = piece.trim();
870
- if (/^([-*_]\s*){3,}$/.test(trimmed)) {
871
- rendered.push("────────────");
872
- continue;
873
- }
874
- rendered.push(renderInlineMarkdown(piece));
1011
+ for (const piece of splitPlainMarkdownLine(line)) {
1012
+ rendered.push(renderMarkdownTextPiece(piece));
875
1013
  }
876
1014
  }
877
1015
  return rendered;
878
1016
  }
879
1017
 
1018
+ function sanitizeTelegramCodeLanguage(language: string): string {
1019
+ return language.split(/\s+/)[0]?.replace(/[^A-Za-z0-9_+.-]/g, "") ?? "";
1020
+ }
1021
+
880
1022
  function renderMarkdownCodeBlock(code: string, language?: string): string[] {
881
- const open = language
882
- ? `<pre><code class="language-${escapeHtml(language)}">`
1023
+ const safeLanguage = language ? sanitizeTelegramCodeLanguage(language) : "";
1024
+ const open = safeLanguage
1025
+ ? `<pre><code class="language-${escapeHtmlAttribute(safeLanguage)}">`
883
1026
  : "<pre><code>";
884
1027
  const close = "</code></pre>";
885
1028
  const maxContentLength = MAX_MESSAGE_LENGTH - open.length - close.length;
@@ -916,6 +1059,64 @@ function renderMarkdownCodeBlock(code: string, language?: string): string[] {
916
1059
  return chunks.length > 0 ? chunks : [`${open}${close}`];
917
1060
  }
918
1061
 
1062
+ function isTelegramTableEmojiGrapheme(grapheme: string): boolean {
1063
+ return (
1064
+ TELEGRAM_TABLE_EMOJI_GRAPHEME_PATTERN.test(grapheme) ||
1065
+ grapheme.includes("\u20e3")
1066
+ );
1067
+ }
1068
+
1069
+ function getTelegramTableCodePointWidth(char: string): number {
1070
+ const codePoint = char.codePointAt(0) ?? 0;
1071
+ if (codePoint === 0 || codePoint < 32) return 0;
1072
+ if (/\p{Mark}/u.test(char)) return 0;
1073
+ if ((codePoint >= 0xfe00 && codePoint <= 0xfe0f) || codePoint === 0x200d)
1074
+ return 0;
1075
+ if (
1076
+ (codePoint >= 0x1100 && codePoint <= 0x115f) ||
1077
+ codePoint === 0x2329 ||
1078
+ codePoint === 0x232a ||
1079
+ (codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
1080
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
1081
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
1082
+ (codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
1083
+ (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
1084
+ (codePoint >= 0xff00 && codePoint <= 0xff60) ||
1085
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6)
1086
+ ) {
1087
+ return 2;
1088
+ }
1089
+ return 1;
1090
+ }
1091
+
1092
+ function getTelegramTableGraphemes(text: string): string[] {
1093
+ if (TELEGRAM_TABLE_GRAPHEME_SEGMENTER) {
1094
+ return Array.from(
1095
+ TELEGRAM_TABLE_GRAPHEME_SEGMENTER.segment(text),
1096
+ (segment) => segment.segment,
1097
+ );
1098
+ }
1099
+ return Array.from(text);
1100
+ }
1101
+
1102
+ function getTelegramTableCellWidth(text: string): number {
1103
+ return getTelegramTableGraphemes(text).reduce((width, grapheme) => {
1104
+ if (isTelegramTableEmojiGrapheme(grapheme)) return width + 2;
1105
+ return (
1106
+ width +
1107
+ Array.from(grapheme).reduce(
1108
+ (sum, char) => sum + getTelegramTableCodePointWidth(char),
1109
+ 0,
1110
+ )
1111
+ );
1112
+ }, 0);
1113
+ }
1114
+
1115
+ function padTelegramTableCellEnd(cell: string, width: number): string {
1116
+ const padding = width - getTelegramTableCellWidth(cell);
1117
+ return padding > 0 ? `${cell}${" ".repeat(padding)}` : cell;
1118
+ }
1119
+
919
1120
  function renderMarkdownTableBlock(lines: string[]): string[] {
920
1121
  const rows = lines.map(parseMarkdownTableRow);
921
1122
  const columnCount = Math.max(...rows.map((row) => row.length), 0);
@@ -929,12 +1130,16 @@ function renderMarkdownTableBlock(lines: string[]): string[] {
929
1130
  const widths = Array.from({ length: columnCount }, (_, columnIndex) => {
930
1131
  return Math.max(
931
1132
  3,
932
- ...normalizedRows.map((row) => (row[columnIndex] ?? "").length),
1133
+ ...normalizedRows.map((row) =>
1134
+ getTelegramTableCellWidth(row[columnIndex] ?? ""),
1135
+ ),
933
1136
  );
934
1137
  });
935
1138
  const formatRow = (row: string[]): string => {
936
1139
  return row
937
- .map((cell, columnIndex) => (cell ?? "").padEnd(widths[columnIndex] ?? 3))
1140
+ .map((cell, columnIndex) =>
1141
+ padTelegramTableCellEnd(cell ?? "", widths[columnIndex] ?? 3),
1142
+ )
938
1143
  .join(" | ");
939
1144
  };
940
1145
  const separator = widths.map((width) => "-".repeat(width)).join(" | ");
@@ -1005,9 +1210,100 @@ interface TelegramRenderedBlockWithSpacing {
1005
1210
  blankLinesBefore: number;
1006
1211
  }
1007
1212
 
1008
- function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
1009
- const normalized = normalizeMarkdownDocument(markdown);
1010
- if (normalized.length === 0) return [];
1213
+ function collectFencedMarkdownCodeLines(
1214
+ lines: string[],
1215
+ index: number,
1216
+ fence: { marker: "`" | "~"; length: number },
1217
+ ): { codeLines: string[]; nextIndex: number; closed: boolean } {
1218
+ const codeLines: string[] = [];
1219
+ let nextIndex = index + 1;
1220
+ while (
1221
+ nextIndex < lines.length &&
1222
+ !isMatchingMarkdownFence(lines[nextIndex] ?? "", fence)
1223
+ ) {
1224
+ codeLines.push(lines[nextIndex] ?? "");
1225
+ nextIndex += 1;
1226
+ }
1227
+ const closed = nextIndex < lines.length;
1228
+ if (closed) nextIndex += 1;
1229
+ return { codeLines, nextIndex, closed };
1230
+ }
1231
+
1232
+ function collectMarkdownTableBlockLines(
1233
+ lines: string[],
1234
+ index: number,
1235
+ ): { tableLines: string[]; nextIndex: number } {
1236
+ const tableLines = [lines[index] ?? ""];
1237
+ let nextIndex = index + 2;
1238
+ while (nextIndex < lines.length) {
1239
+ const tableLine = lines[nextIndex] ?? "";
1240
+ if (tableLine.trim().length === 0 || !tableLine.includes("|")) break;
1241
+ tableLines.push(tableLine);
1242
+ nextIndex += 1;
1243
+ }
1244
+ return { tableLines, nextIndex };
1245
+ }
1246
+
1247
+ function collectIndentedMarkdownCodeLines(
1248
+ lines: string[],
1249
+ index: number,
1250
+ ): { codeLines: string[]; nextIndex: number } {
1251
+ const codeLines: string[] = [];
1252
+ let nextIndex = index;
1253
+ while (nextIndex < lines.length) {
1254
+ const rawLine = lines[nextIndex] ?? "";
1255
+ if (rawLine.trim().length === 0) {
1256
+ codeLines.push("");
1257
+ nextIndex += 1;
1258
+ continue;
1259
+ }
1260
+ if (!isIndentedCodeLine(rawLine)) break;
1261
+ codeLines.push(stripIndentedCodePrefix(rawLine));
1262
+ nextIndex += 1;
1263
+ }
1264
+ return { codeLines, nextIndex };
1265
+ }
1266
+
1267
+ function collectMarkdownQuoteBlockLines(
1268
+ lines: string[],
1269
+ index: number,
1270
+ ): { quoteLines: string[]; nextIndex: number } {
1271
+ const quoteLines: string[] = [];
1272
+ let nextIndex = index;
1273
+ while (nextIndex < lines.length && /^\s*>/.test(lines[nextIndex] ?? "")) {
1274
+ quoteLines.push(lines[nextIndex] ?? "");
1275
+ nextIndex += 1;
1276
+ }
1277
+ return { quoteLines, nextIndex };
1278
+ }
1279
+
1280
+ function isMarkdownTextBlockBoundary(lines: string[], index: number): boolean {
1281
+ const current = lines[index] ?? "";
1282
+ const following = lines[index + 1] ?? "";
1283
+ if (current.trim().length === 0) return true;
1284
+ if (isFencedCodeStart(current)) return true;
1285
+ if (canStartIndentedCodeBlock(lines, index)) return true;
1286
+ if (/^\s*>/.test(current)) return true;
1287
+ return current.includes("|") && isMarkdownTableSeparator(following);
1288
+ }
1289
+
1290
+ function collectMarkdownTextBlockLines(
1291
+ lines: string[],
1292
+ index: number,
1293
+ ): { textLines: string[]; nextIndex: number } {
1294
+ const textLines: string[] = [];
1295
+ let nextIndex = index;
1296
+ while (nextIndex < lines.length) {
1297
+ if (isMarkdownTextBlockBoundary(lines, nextIndex)) break;
1298
+ textLines.push(lines[nextIndex] ?? "");
1299
+ nextIndex += 1;
1300
+ }
1301
+ return { textLines, nextIndex };
1302
+ }
1303
+
1304
+ function renderMarkdownDocumentBlocks(
1305
+ normalizedMarkdown: string,
1306
+ ): TelegramRenderedBlockWithSpacing[] {
1011
1307
  const renderedBlocks: TelegramRenderedBlockWithSpacing[] = [];
1012
1308
  let minimumBlankLinesBeforeNextBlock = 0;
1013
1309
  const pushRenderedBlocks = (
@@ -1026,7 +1322,7 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
1026
1322
  }
1027
1323
  minimumBlankLinesBeforeNextBlock = 0;
1028
1324
  };
1029
- const lines = normalized.split("\n");
1325
+ const lines = normalizedMarkdown.split("\n");
1030
1326
  let index = 0;
1031
1327
  let pendingBlankLines = 0;
1032
1328
  while (index < lines.length) {
@@ -1052,125 +1348,120 @@ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
1052
1348
  }
1053
1349
  const fence = parseMarkdownFence(line);
1054
1350
  if (fence) {
1055
- index += 1;
1056
- const codeLines: string[] = [];
1057
- while (
1058
- index < lines.length &&
1059
- !isMatchingMarkdownFence(lines[index] ?? "", fence)
1060
- ) {
1061
- codeLines.push(lines[index] ?? "");
1062
- index += 1;
1063
- }
1064
- if (index < lines.length) {
1065
- index += 1;
1066
- }
1351
+ const block = collectFencedMarkdownCodeLines(lines, index, fence);
1352
+ index = block.nextIndex;
1067
1353
  pushRenderedBlocks(
1068
- renderMarkdownCodeBlock(codeLines.join("\n"), fence.info),
1354
+ renderMarkdownCodeBlock(block.codeLines.join("\n"), fence.info),
1069
1355
  pendingBlankLines,
1070
1356
  );
1071
1357
  pendingBlankLines = 0;
1072
1358
  continue;
1073
1359
  }
1074
1360
  if (line.includes("|") && isMarkdownTableSeparator(nextLine)) {
1075
- const tableLines: string[] = [line];
1076
- index += 2;
1077
- while (index < lines.length) {
1078
- const tableLine = lines[index] ?? "";
1079
- if (tableLine.trim().length === 0 || !tableLine.includes("|")) {
1080
- break;
1081
- }
1082
- tableLines.push(tableLine);
1083
- index += 1;
1084
- }
1361
+ const block = collectMarkdownTableBlockLines(lines, index);
1362
+ index = block.nextIndex;
1085
1363
  pushRenderedBlocks(
1086
- renderMarkdownTableBlock(tableLines),
1364
+ renderMarkdownTableBlock(block.tableLines),
1087
1365
  pendingBlankLines,
1088
1366
  );
1089
1367
  pendingBlankLines = 0;
1090
1368
  continue;
1091
1369
  }
1092
1370
  if (canStartIndentedCodeBlock(lines, index)) {
1093
- const codeLines: string[] = [];
1094
- while (index < lines.length) {
1095
- const rawLine = lines[index] ?? "";
1096
- if (rawLine.trim().length === 0) {
1097
- codeLines.push("");
1098
- index += 1;
1099
- continue;
1100
- }
1101
- if (!isIndentedCodeLine(rawLine)) break;
1102
- codeLines.push(stripIndentedCodePrefix(rawLine));
1103
- index += 1;
1104
- }
1371
+ const block = collectIndentedMarkdownCodeLines(lines, index);
1372
+ index = block.nextIndex;
1105
1373
  pushRenderedBlocks(
1106
- renderMarkdownCodeBlock(codeLines.join("\n")),
1374
+ renderMarkdownCodeBlock(block.codeLines.join("\n")),
1107
1375
  pendingBlankLines,
1108
1376
  );
1109
1377
  pendingBlankLines = 0;
1110
1378
  continue;
1111
1379
  }
1112
1380
  if (/^\s*>/.test(line)) {
1113
- const quoteLines: string[] = [];
1114
- while (index < lines.length && /^\s*>/.test(lines[index] ?? "")) {
1115
- quoteLines.push(lines[index] ?? "");
1116
- index += 1;
1117
- }
1381
+ const block = collectMarkdownQuoteBlockLines(lines, index);
1382
+ index = block.nextIndex;
1118
1383
  pushRenderedBlocks(
1119
- renderMarkdownQuoteBlock(quoteLines),
1384
+ renderMarkdownQuoteBlock(block.quoteLines),
1120
1385
  pendingBlankLines,
1121
1386
  );
1122
1387
  pendingBlankLines = 0;
1123
1388
  continue;
1124
1389
  }
1125
- const textLines: string[] = [];
1126
- while (index < lines.length) {
1127
- const current = lines[index] ?? "";
1128
- const following = lines[index + 1] ?? "";
1129
- if (current.trim().length === 0) break;
1130
- if (
1131
- isFencedCodeStart(current) ||
1132
- canStartIndentedCodeBlock(lines, index) ||
1133
- /^\s*>/.test(current)
1134
- )
1135
- break;
1136
- if (current.includes("|") && isMarkdownTableSeparator(following)) break;
1137
- textLines.push(current);
1138
- index += 1;
1139
- }
1390
+ const block = collectMarkdownTextBlockLines(lines, index);
1391
+ index = block.nextIndex;
1140
1392
  pushRenderedBlocks(
1141
- renderMarkdownTextBlock(textLines.join("\n")),
1393
+ renderMarkdownTextBlock(block.textLines.join("\n")),
1142
1394
  pendingBlankLines,
1143
1395
  );
1144
1396
  pendingBlankLines = 0;
1145
1397
  }
1398
+ return renderedBlocks;
1399
+ }
1400
+
1401
+ interface TelegramMarkdownChunkAccumulator {
1402
+ chunks: string[];
1403
+ current: string;
1404
+ }
1405
+
1406
+ function flushTelegramMarkdownChunkAccumulator(
1407
+ accumulator: TelegramMarkdownChunkAccumulator,
1408
+ ): void {
1409
+ if (accumulator.current.length === 0) return;
1410
+ accumulator.chunks.push(accumulator.current);
1411
+ accumulator.current = "";
1412
+ }
1413
+
1414
+ function splitOversizedTelegramMarkdownBlock(text: string): string[] {
1146
1415
  const chunks: string[] = [];
1147
- let current = "";
1148
- for (const block of renderedBlocks) {
1149
- const separator = "\n".repeat(block.blankLinesBefore + 1);
1150
- const candidate =
1151
- current.length === 0 ? block.text : `${current}${separator}${block.text}`;
1152
- if (candidate.length <= MAX_MESSAGE_LENGTH) {
1153
- current = candidate;
1154
- continue;
1155
- }
1156
- if (current.length > 0) {
1157
- chunks.push(current);
1158
- current = "";
1159
- }
1160
- if (block.text.length <= MAX_MESSAGE_LENGTH) {
1161
- current = block.text;
1162
- continue;
1163
- }
1164
- for (let i = 0; i < block.text.length; i += MAX_MESSAGE_LENGTH) {
1165
- chunks.push(block.text.slice(i, i + MAX_MESSAGE_LENGTH));
1166
- }
1167
- }
1168
- if (current.length > 0) {
1169
- chunks.push(current);
1416
+ for (let i = 0; i < text.length; i += MAX_MESSAGE_LENGTH) {
1417
+ chunks.push(text.slice(i, i + MAX_MESSAGE_LENGTH));
1170
1418
  }
1171
1419
  return chunks;
1172
1420
  }
1173
1421
 
1422
+ function appendTelegramRenderedMarkdownBlock(
1423
+ accumulator: TelegramMarkdownChunkAccumulator,
1424
+ block: TelegramRenderedBlockWithSpacing,
1425
+ ): void {
1426
+ const separator = "\n".repeat(block.blankLinesBefore + 1);
1427
+ const candidate =
1428
+ accumulator.current.length === 0
1429
+ ? block.text
1430
+ : `${accumulator.current}${separator}${block.text}`;
1431
+ if (candidate.length <= MAX_MESSAGE_LENGTH) {
1432
+ accumulator.current = candidate;
1433
+ return;
1434
+ }
1435
+ flushTelegramMarkdownChunkAccumulator(accumulator);
1436
+ if (block.text.length <= MAX_MESSAGE_LENGTH) {
1437
+ accumulator.current = block.text;
1438
+ return;
1439
+ }
1440
+ accumulator.chunks.push(...splitOversizedTelegramMarkdownBlock(block.text));
1441
+ }
1442
+
1443
+ function chunkTelegramRenderedMarkdownBlocks(
1444
+ renderedBlocks: TelegramRenderedBlockWithSpacing[],
1445
+ ): string[] {
1446
+ const accumulator: TelegramMarkdownChunkAccumulator = {
1447
+ chunks: [],
1448
+ current: "",
1449
+ };
1450
+ for (const block of renderedBlocks) {
1451
+ appendTelegramRenderedMarkdownBlock(accumulator, block);
1452
+ }
1453
+ flushTelegramMarkdownChunkAccumulator(accumulator);
1454
+ return accumulator.chunks;
1455
+ }
1456
+
1457
+ function renderMarkdownToTelegramHtmlChunks(markdown: string): string[] {
1458
+ const normalized = normalizeMarkdownDocument(markdown);
1459
+ if (normalized.length === 0) return [];
1460
+ return chunkTelegramRenderedMarkdownBlocks(
1461
+ renderMarkdownDocumentBlocks(normalized),
1462
+ );
1463
+ }
1464
+
1174
1465
  // --- Unified Telegram Rendering ---
1175
1466
 
1176
1467
  export type TelegramRenderMode = "plain" | "markdown" | "html";
@@ -1245,7 +1536,10 @@ export function renderTelegramMessage(
1245
1536
  return chunkParagraphs(text).map((chunk) => ({ text: chunk }));
1246
1537
  }
1247
1538
  if (mode === "html") {
1248
- return [{ text, parseMode: "HTML" }];
1539
+ return chunkHtmlPreservingTags(text, MAX_MESSAGE_LENGTH).map((chunk) => ({
1540
+ text: chunk,
1541
+ parseMode: "HTML",
1542
+ }));
1249
1543
  }
1250
1544
  return renderMarkdownToTelegramHtmlChunks(text).map((chunk) => ({
1251
1545
  text: chunk,